github stephrobert/feint v0.12.0

latest releases: v0.13.0, v0.12.1
one month ago

The release where three runtime paths became one contract, and the whole of
feint was replayed to check the simplification was not paid for in fidelity.

Two packs of three had quietly forgotten to hand their security groups to the
host, and it survived for months because every test was green (#475). So the
refactor was the means and the proof was the point: an acceptance gate that adds
no product code and cannot close until the API, Terraform, the real clients, real
machines, networks, firewalls and balancers have all been driven again (#545).

They were. 220 verdicts under incus-ovn with one skip — named, pointing at
where its proof lives instead — 988 falsification mutations of which none
survived, and no axis of evidence fell against 0.11.0.

That last sentence is the one this release had no way to check. The invariant was
written in the milestone and verified by hand, so the machinery for it was
brought forward from 0.15.0: a downstream project can now pin the level of proof
it depends on and have its own CI fail when feint stops delivering it (#488).
The instrument was built rather than the claim asserted.

Added

  • A downstream project can pin the level of proof it depends on, and its own
    CI fails when feint stops delivering it: feint evidence baseline and
    feint evidence verify, CLI surface 21 (#488).
    Not "feint changed
    version"
    — "feint stopped proving what this project was relying on".
    #325 and #326 exist because a consumer discovered a change from the outside,
    after the fact.

    This is not drift:check, and the boundary is the design: that one
    watches the upstream SDK surface — has the cloud moved? — and this one
    watches this emulator's own level of evidence, from the point of view of
    somebody who is not in this repository — has what I trusted moved? A design
    folding one into the other answers one question twice and the other never.

    Only what is proven is pinned. An axis whose verdict is the absence of
    proof — false, none, unchecked, unobserved — is dropped at capture,
    because pinning it turns every later improvement into a regression. Measured:
    the first version of this pinned them, and run against v0.11.0 it reported
    osc/Client.AcceptNetPeering behaviour: false → true as a fall.

    Claims are meant to be withdrawn here. #475, #481 and #483 are each "this
    was claimed and should not have been"
    , and a baseline that only grows would
    have stopped all three. --accepted carries a withdrawal with its reason,
    on the model corpus/accepted.json already uses; an entry with no reason is
    refused rather than honoured, because the whole value of the file is that a
    withdrawal carries why.

    A baseline from a partial run is worse than none, so a record earned with
    no machine runtime is refused at capture: it pins dataplane: false on every
    operation that would have earned it, and the consumer is then told nothing
    regressed on the day it did.

    mise run evidence:verify holds the tree against the previous release. Run
    today against v0.11.0: 364 operations pinned on seven axes, every level
    still delivered
    , which is 0.12.0's release invariant measured by an
    instrument rather than by hand.

  • The operator declares which projects the emulated account holds:
    cloud.projects in feint.yaml, serve --projects, and the CLI surface
    moves to 20 (#572).
    Until now this emulator held one project, named
    default, and a stack whose project_name was its own production project
    died on the Terraform provider's FindExact after a truthful empty list —
    the obstacle for exactly the person #372 exists to serve, a platform team
    pointing an existing stack at the emulator. data "scaleway_account_project"
    now resolves a declared name, and GetProject reads it back as itself
    instead of answering default for everything.

    The list still filters. A name nobody declared answers an empty list, and
    that is the whole design: the cheap fix was to answer that a project exists
    because somebody asked for it, which is the class #83 measured and closed on
    all three packs — an identifier no catalogue held, silently substituted, and
    a green run that meant nothing. The inventory stays something the operator
    stated.

    Identifiers are derived from the name and never minted per run, for the
    reason the project's created_at is fixed: a value that moved between two
    reads is a permanent Terraform diff. default keeps the identifier every
    other product of this pack already scopes its answers to, so a declaration
    that omits the field — every declaration written before today — gets exactly
    the account it had.

  • A word for the shape the vocabulary could not describe:
    capabilities.firewall_public_when_joined, and /_feint/health moves to
    schema 7 (#548).
    A consumer holding capabilities.firewall: true,
    enforced.firewall and firewall_public_only: false concluded that the one
    uncovered case was the machine with no private network. It was not, and the
    fix below is why the field exists: a machine that does join an emulated
    network now ends up with its published address on the interface that wears
    its rule sets, and that is a different claim from the one version 5 added.
    Both are true at once, and both are needed: the older field is still false,
    because a machine whose only interface is routed has nowhere to move an
    address to. The new one is a claim about the runtime and not about every
    pack — a pack that names no emulated network for its public addresses keeps
    them on a routed NIC, which Exoscale does on purpose.

  • The example stacks are applied to real machines every night, and the gate
    that does it says how many times (#504).
    conformance:functional is the
    only thing here that applies examples/stacks/ against a runtime, and
    nothing in CI played it: no leg of runtime-proof.yml applies a stack, and
    mise run conformance skips the stack suites without one. It is what
    surfaced the isolation-detach race that cost three pull requests (#577,
    #578), on a defect older than the whole range bisected. It now runs as a job
    of its own on that workflow — a job rather than a step, because the incus-ovn
    leg's ssh suites were red on four of the last seven scheduled nights and each
    of them leaves rule sets and networks feint stop does not sweep, so a step
    there would queue behind a red and then meet a doorstep it could not pass.
    Three passes, and the number is an arbitrage: the defect class struck 9
    times in 13 runs, so one pass calls it absent 31% of the time, and three bring
    that to 3%, at 295 s a pass.

  • The Account product's projects, so a third-party VPC stack reaches a VPC
    path at all: account/v3/ProjectAPI.ListProjects and
    account/v3/ProjectAPI.GetProject (#372).
    data "scaleway_account_project"
    is evaluated ahead of every resource in a Terraform graph, and it answered
    501: two published modules that walk the VPC surface died in two exchanges
    each, before a single VPC, ACL, private network, gateway or IPAM address was
    attempted. Both routes are mounted because the provider's own read calls both
    — ListProjects when the configuration names the project, then always
    GetProject on the id it resolved. The whole account product joined the drift
    gate with them: twelve operations, two served, ten declined with a reason.

    The list answers one project, named default, and filters name and
    project_ids honestly — a name this emulator does not carry answers an empty
    list. The read echoes any identifier, which is
    limits.md's "identifiers are not checked against anything"
    applied to the value a stack most often carries over from production. The cost
    is stated there too: a stack whose project_name is not default fails on
    the provider's own FindExact.

  • A capability matrix owns every sentence that claims a client for a
    provider, and docs:check reads the pages back (#592).
    README.md:41 said
    "Run your Terraform against Scaleway, Outscale or Exoscale" while
    docs/confidence.md said the opposite and feint up had been refusing
    iac.engine: terraform for that pack since #525 landed on 2026-08-26. Every
    doc gate stayed green throughout, and none of them was wrong: they compare
    values an artefact also holds, and a claim about capability is not one.
    The matrix (internal/cli/capability.go) is provider × client × mode ×
    support × proof × reason, and the proof column is what makes it more than
    a table: a supported row is resolved against the conformance workflow that
    drives that pair, a refused row against the pack's own VetoEngine — the
    code up and down consult — and both directions are checked, so a veto
    nobody wrote down and a row nothing proves are equally refused. The README's
    promise is generated from it in both languages, and every generated block of
    the front pages is read back: a sentence naming a refused pair passes only if
    it also names the upstream issue that would change it. Exit code 2, like the
    rest of the chain.

  • The Quick Start is four commands a reader can copy, on a stack short enough
    to read whole (#593).
    It taught the 0.10 sequence — feint start, eval "$(feint env scaleway)", terraform apply — with no directory, no main.tf
    and no provider block, under an Apply complete! Resources: 5 added that was
    not reachable from it. examples/quickstart/scaleway and
    examples/quickstart/outscale are the first example now: a provider, an
    address, one machine, 34 and 43 lines of Terraform, runtime: mode: off.
    examples/stacks/ is unchanged and keeps its job — it is the qualification
    stack that found #249 and #250, and it was being asked to be a first read as
    well. The apply line is derived from the configuration and
    tools/conformance/quickstart.sh lifts it out of the README and requires the
    run to print it, so the output shown is the output produced. The suite runs
    feint up, an empty second plan and feint down on both examples, on the
    terraform and opentofu legs, and the quickstart directory joins the population
    feint docs --check already judges: applied by CI or declared with a reason,
    and pinning the provider that answered.

  • The generated blocks that carry prose are rendered per locale (#591). The
    French README opened with an English Quick Start — "On your machine", "In CI,
    or anywhere Docker runs" — because the generator injected one block into both
    pages on the rule that a command needs no translation. True of the commands,
    and they are still shared along with the version, the image and the
    repository; the sentences around them are written twice.

Fixed

  • A single feint docs run that changed two sections of a README kept only
    one of them.
    The target was written from a copy spliced at the top of the
    run, after the helpers that re-read the same file and splice into what they
    find — so it put their sections back and reported success, and the only
    symptom was docs --check still red after a regeneration that had said
    README.md updated. Measured while adding the promise block.

  • A server created with its public IP no longer keeps an unfiltered
    interface beside its filtered one (#548).
    Created with an ip_id, a
    Scaleway server boots carrying only that address, so the driver gives it a
    routed NIC (#202) — an interface Incus accepts no security option on at all
    (#337). The private NIC arrived afterwards, took the rule set, and left the
    published address on the bare one: measured 2026-08-27 on
    examples/stacks/scaleway and reproduced from the API alone on 2026-08-28 in
    both driver modes, a port the group's drop default never opened
    answered from the station, with a listener proved inside the machine and a
    bare port as the negative control.

    The address moves now. RouteAddress releases it from the routed device
    without removing the device — the two other remedies were tried and refused,
    and docs/limits.md keeps both: the uplink cannot take the /32 while the
    routed NIC owns the host route, and removing the device unmasks the
    profile's eth0 on the operator's own bridge — then hands it to the
    interface the pack named, which wears the rule sets. After it, in both modes:
    443 open because a rule opens it, the port no rule names closed with its
    listener still running, and the bare port as the control. The routed device
    stays on the instance with no address, and a routed NIC that carries nothing
    is no longer reported as an escape.

  • What a machine answers on is read, not guessed, and a restarted machine
    gets its interface back (#548).
    Inspect used to answer one address — the
    first of the lowest-named interface — and the layer above then decided what
    kind of address that was from the pack's declared public block. The two
    agreed only while a routed NIC sorted before a managed one, which is exactly
    what the move above ends: after it the runtime answers
    {"eth0":[],"eth1":["10.199.0.2","203.0.113.2"]}, and the old reading would
    have published the private address where it published the public one — for
    three packs at once, since Binding is the shared layer and the Exoscale
    pack reads it. The driver reports every address it saw and settles nothing;
    Reconciler.PublicAddressOf and PrivateAddressOf pick out of that set by
    the block, which is the pack's own declaration. Nothing a client sees
    changes, and an entry a restored snapshot put there that is not an address is
    now dropped rather than published — only the public half ever went through a
    parser.

    The restart half was measured the same day and had to be fixed for the move
    to survive one: a NIC attached to a running machine is configured inside the
    guest by the driver, nothing in the guest remembers that across a reboot, and
    the machine came back with no address on that interface at all — the driver's
    own ninety-second wait giving up on a lease nobody offers, in both modes.
    While the published address rode a routed NIC of its own that cost only the
    routes to the peered subnets (#549); once it lives on that interface it costs
    the address itself. The start path restores what the device reserves before
    it waits.

    And the record a boot leaves is re-read at the end of the replay, because it
    was written before the replay installed the addresses the plan promised: the
    same rebooted machine recorded 10.199.0.2 alone while the station reached
    203.0.113.2 on it in the same pass. The pack's own bookkeeping hook moved
    with that read, from straight after the start to just after it — it ran early
    by exactly those addresses, and the Outscale test that keeps a stopped Vm's
    private address is what measured it.

  • A graceful exit gives back every piece of host plumbing no client can
    delete, and a run that leaks is the run that goes red (#521).
    The incus-ovn
    leg of runtime-proof.yml failed at the doorstep of the next step on a
    GitHub runner nothing had ever touched: a previous run left 0 machine(s) and
    2 network(s) on this host
    , naming feint-uplink, fnt-default and the rule
    sets of two providers' default security groups. There was no previous run —
    the leg's own three ssh suites had made all four, each exiting 0.

    Four objects, one property: no client call can remove any of them, so
    leaving them measured nothing about the suites. fnt-default is the network
    a machine with no attachment of its own boots on, created here by an Outscale
    Vm outside a Net and owned by no resource; feint-uplink had been released
    since #521 and stayed because fnt-default still drew from it; the scw-*
    and exo-* rule sets belong to default security groups a client cannot
    delete, and Scaleway's is minted per run, so one host ACL accumulated per
    session. The exit now releases all four, in the only order the runtime
    accepts, and each release answers both questions — is it ours, and is
    anything still drawing from it — so a feint stop without --cleanup leaves
    the firewall on the machines it deliberately leaves running.

    The second half is the sentence. feint clean --check --closing and
    tools/conformance/guard.sh leftovers-after ask the identical question and
    name this run, runtime-proof.yml asks it after its own stop instead of
    letting the next step meet the residue, and the doorstep now names the rule
    sets it does not refuse on rather than passing over them in silence. The
    ledger's attribution column was corrected with them: a network is found by
    the label Survey reads, not by the fnt- prefix the column claimed — and
    the first network the failing leg reported, feint-uplink, carries no such
    prefix.

  • The stack gate's closing doorstep asks the question its own comment
    claimed, and the gate stops choosing its runtime in silence (#504).
    It
    closed with guard_leftovers_for "$RUNTIME" "the end of the run" under a
    paragraph saying the doorstep was asked again on the way out; the guard arms
    that question on the literal doorstep alone, so a pass that leaked a
    machine or a network exited 0 and the next run met the refusal — the state
    #521 removed from mise run conformance, described here and never done. And
    it opened with RUNTIME="${FEINT_FUNCTIONAL_RUNTIME:-incus-ovn}", so an
    exported FEINT_VM was ignored without a word: #574's line one directory
    over, on the one gate whose subject is the assertion the two modes disagree
    about. The resolution moved to tools/runtime-mode.sh, shared with
    evidence:update, whose unchanged tests are what prove the move changed
    nothing.

  • The Exoscale stack's two null outputs are not this emulator's (#520). The
    filing deduced, without instrumenting it, that the pack's by-id GET raced its
    own async create-completion. Measured on 2026-08-28 against the pack
    directly: 100 back-to-back by-id reads of one balancer, 100 fresh
    create-then-read pairs, and 50 for the instance pool — zero empty names,
    addresses or sizes. There is no window: the create puts name and ip in
    the store before it writes an operation that already says state: success.
    The stack's third by-id door read back correctly in the same apply, so
    whatever produced the nulls tells the three apart and this pack does not.
    docs/limits.md carries the measurement and the disproof; what would lift
    the limit is upstream terraform-provider-exoscale#573, and no change to
    this pack would.

  • An Exoscale security group stops at the public interface, where the real
    cloud stops it (#574).
    Exoscale states it in one sentence: "Security group
    rules do not apply to traffic inside private networks". Since a344f8d
    (#494/#475) this emulator applied them there. The emulated default group
    carries no ingress rule, so its rule set translates to a drop default, and
    the driver wrote it onto the membership NIC: two instances of one private
    network could not reach each other
    . Measured 2026-08-27 under --vm incus,
    one network and two instances created with --public-ip none, whose only
    interface is the membership one: security.acls=exo-… plus
    security.acls.default.ingress.action=drop on the NIC, 0/10 probes
    connected; with the fix, no security.acls key at all, 10/10. Under
    --vm incus-ovn the same wrong rule set was written and did not bite,
    because the sender's catch-all egress allow at priority 300 outranks the
    receiver's NIC default deny at 100/111 (#491) — so every green that segment
    produced under OVN rested on rule ordering, and the after-state is read off
    the NIC rather than off a connection succeeding.

    Where a security group applies is now a provider fact carried by the shared
    layer, not a rule of the layer: the pack declares it per interface on
    machine.Attachment.Unfiltered, machine.GroupSync reads it off the plan
    the pack already declares, and it reaches the driver on
    FirewallBinding.Unfiltered. Scaleway and Outscale declare nothing and keep
    filtering their private NICs, which their own network suites assert.
    Consequence a reader of the host will meet: an Exoscale instance with no
    public address and only private memberships carries no rule set on any
    interface
    , because it has no interface a group covers.

  • The three instruments that hid it, repaired in the same change (#574).
    .github/workflows/runtime-proof.yml ran two of the three network suites and
    not tools/conformance/exoscale/network.sh, on either leg, so nothing in CI
    replayed it; TestEveryNetworkSuiteIsReplayedByTheRuntimeProof now derives
    the list from the suites on disk rather than from a list somebody remembers.
    mise run evidence:update overrode an exported FEINT_VM in silence on its
    runtime leg (FEINT_VM="${FEINT_EVIDENCE_VM:-incus}"), which manufactured
    two successive false attributions during the diagnosis; tools/evidence/mode.sh
    now honours the caller, refuses a disagreement and --vm off by name, and
    announces the mode it runs before anything starts. And the Exoscale
    network suite's own header claimed the pack "does not yet sync its security
    groups onto the machines" — true before a344f8d, read as a live fact for
    months after, and what steered every reader away from the firewall.

  • A server answer carries bootscript and extra_networks (#366), a
    catalogue image types from_server as a string (#367), and an attached public
    address publishes its gateway and its own tags (#368).
    Four response shapes
    a client can observe, all four measured against a real fr-par account on
    2026-08-21 and again on 2026-08-24 and all four recorded in
    corpus/accepted.json until now: omitting a key is not the same answer as
    writing it null, and feint corpus --check went from 497 knowingly-accepted
    divergences to 425 with the thirty-seven exemptions these retire.

  • A block volume goes back to available when the server holding it goes, so
    scw instance server delete returns (#365).
    Reachable since sbs_volume
    was honoured: the CLI polls the volume the server's own answer named until it
    settles, and nothing ever put the status back — measured at rc=124, never
    returning, with five identical polls in the client's trace. #365 itself stays
    open: making that volume the DEV1-S default, which is what the cloud does,
    moves every root disk out of instance/v1 where the whole server-volume
    relationship is implemented, and that is a decision rather than a line.

Proved against

The clients

Client Version Pinned in Drives, in CI
scw 2.56.3 .github/workflows/conformance.yml (SCW_VERSION) Scaleway
Terraform 1.13.3 .github/workflows/conformance.yml (TERRAFORM_VERSION) Outscale, Scaleway
OpenTofu 1.12.5 .github/workflows/conformance.yml (TOFU_VERSION) Outscale, Scaleway
octl 0.0.31 .github/workflows/conformance.yml (OCTL_VERSION) Outscale
exo 1.95.6 .github/workflows/conformance.yml (EXO_VERSION) Exoscale

The Terraform providers

The client above is the engine; what answers this emulator is the provider.
Each row is one required_providers entry, read where it is written.

Fixture or stack Provider Constraint What that is worth Applied in CI
tools/conformance/faults outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
tools/conformance/outscale/terraform outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
tools/conformance/outscale/terraform-doorway outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
tools/conformance/scaleway/terraform scaleway/scaleway 2.81.0 exact: the version that answered yes
examples/stacks/exoscale exoscale/exoscale — not pinned: whatever the registry served that day no
examples/stacks/outscale outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
examples/stacks/outscale/modules/net outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
examples/stacks/scaleway scaleway/scaleway 2.81.0 exact: the version that answered yes
examples/quickstart/outscale outscale/outscale ~> 1.7 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
examples/quickstart/scaleway scaleway/scaleway 2.81.0 exact: the version that answered yes

Read the third column narrowly, because it is the one a consumer pins against.
An exact constraint names the version that answered. A constraint is
resolved by terraform init -upgrade on the runner, every run, so the version
that answered is not knowable from this repository — it is a floor, not a
proof. Not pinned is not pinned: nothing here says which version ran, and
no number is invented to fill the cell.

The stacks the last column says CI does not apply are declared rather than
merely absent, so a no is a decision somebody wrote down and not a stack
nobody wired up:

  • examples/stacks/exoscale — suspended — no Terraform for Exoscale
    until upstream exoscale/terraform-provider-exoscale#573 is fixed: the
    published provider honours EXOSCALE_API_ENDPOINT for one of the two
    clients it builds, so an apply splits between the emulator and a paying
    account, and #525 measured five signed requests leaving for the real cloud
    from a feint down on this stack (2026-08-26). feint up refuses the
    engine at the doorstep; the exo CLI drives the pack instead. What the
    pinned fork had proved stays dated in docs/limits.md: 16 resources, empty
    second plan, clean destroy, 2026-08-24.

Generated from the conformance workflow and the fixtures at this tag.
A newer client is not known to work until the pin moves and the suite
passes on it: docs/clients.md says what each cell is worth.

Don't miss a new feint release

NewReleases is sending notifications on new releases.