github stephrobert/feint v0.13.0

one month ago

Added

  • Elastic Metal is listed: baremetal/v1/API.ListServers, and the
    catalogue the CLI joins to it, baremetal/v1/API.ListOffers
    (#631). The
    product is not emulated and nothing else of it is: no offer is in stock,
    nothing is ordered, installed or rebooted, and the thirty-five other
    operations are declined with their reason. The listing exists because a
    fleet inventory has to enumerate the product to describe a mixed fleet, and
    it was measured before it was written: scw baremetal server list (2.56.3)
    calls GET /baremetal/v1/zones/{zone}/servers?order_by=created_at_asc&page=1
    and stopped on the 501 the unrouted prefix answered; the Python SDK's
    list_servers_all (scaleway 2.12.0) pages with page alone until a page
    comes back empty. The issue sized the product at one route and the CLI's
    source says two: once the servers answered, the same command read
    ListOffers and printed the catalogue's name for each server's offer_id,
    so the catalogue answers one offer per distinct offer_id among the seeded
    servers, stock: empty, nothing invented around the name the seed gave it.
    A server enters through the state door (serve --state,
    PUT /_feint/state) as Kind baremetal/server with the SDK's field names in
    Attrs, and the view answers the SDK's whole Server field set,
    ips[].version included, derived from the address when the seed did not
    say. The product is under the drift gate and the contract (elastic-metal/v1,
    the portal's slug for the SDK's baremetal), and the scw suite seeds one
    server and lists it. The answer for a zone outside the six the product
    declares is this pack's convention, not a recording; docs/limits.md says
    so.

  • An operator who declares their catalogue gets their typos refused, and
    nobody else changes
    (#126): feint serve --strict-catalog catalog.json,
    and feint start carries it. The file names, per provider, the images,
    templates and machine types the project allows; a create outside it is
    refused in each cloud's own error shape, and so is the lookup the client
    makes first, so scw, octl and exo render their ordinary
    not-found path. What each pack refuses and in which shape, and which of
    those shapes were recorded on a real account, is a table in
    docs/limits.md: osc/Client.CreateVms answers the recorded
    400/5023/InvalidResource; instance/v1/API.GetImage,
    marketplace/v2/API.ListLocalImages and instance/v1/API.CreateServer
    the recorded 404 not_found; exoscale/v2.create-instance and
    create-instance-pool this pack's 404, unrecorded on a create. A kind
    the file does not mention is not checked; a kind no pack checks is refused
    before anything listens, because a line nobody enforces reads exactly like
    one somebody does. Without the flag nothing moves: the compatibility mode
    is byte-identical, the conformance suite runs in it, and
    docs/limits.md's "identifiers are not checked" stays true word for word.

  • An Outscale volume is creating before it is available, a snapshot
    in-queue before it is completed, and a snapshot taken during creating
    meets the measured 409 InvalidVolumeState
    (#124), under
    feint serve --consistency eventual. Measured against a real account on
    2026-08-08: osc/Client.CreateVolume answers State: "creating",
    osc/Client.CreateSnapshot issued before the volume settles is refused with
    409 and code 6007, and a snapshot is born in-queue with Progress: 0.
    Here both settled at once, so that refusal could never fire, and a guard for
    it had been tried and reverted for exactly that reason. #637's scheduler
    makes the state reachable without a clock, one observation per read; the
    guard reads the volume without observing it (Store.Peek), because the
    first version consumed the very state it checked. The Details wording was
    not recorded and is this emulator's; the status, type and code are the
    measurement's, and osc.IsConflict classifies the code as it classifies the
    cloud's. With the mode off, nothing moves: a volume is available and a
    snapshot completed at once, byte for byte, and a test holds that half.

  • A backend gains and loses one server at a time (lb/v1/ZonedAPI.AddBackendServers,
    lb/v1/ZonedAPI.RemoveBackendServers, #676). Declined because the Terraform
    provider reconciles a pool through SetBackendServers alone, which is true
    of Terraform and not of a Day-2 client, for whom adding one server is the
    ordinary week-three operation. Measured on a real account on 2026-09-04 and
    copied: an added address goes first in the pool; an address the pool already
    holds refuses the whole request with invalid_arguments on server_ip,
    "server_ip x already exists"; a removed address leaves the others, and
    removing one the pool does not hold answers 200 with the pool unchanged. The
    order of several addresses added at once was not measured, and the handler
    says so. Driven by scw lb backend add-servers and remove-servers.

  • Four Load Balancer reads a Day-2 client asks for are served
    (lb/v1/ZonedAPI.GetLBStats, lb/v1/ZonedAPI.ListBackendStats,
    lb/v1/ZonedAPI.ListCertificates, lb/v1/ZonedAPI.ListSubscriber, #666).
    Measured on both sides on 2026-09-04 with one generated Ansible collection,
    39 of its 46 modules play against a real account and 28 here, and these
    four routes were most of the difference.

    The two stats routes report on servers this emulator already owns, in a
    backend it already serves: backend_id and instance_id are identifiers it
    minted, ip is the pool's, server_state is the state of the server whose
    NIC holds that address (stopped for an address nobody holds), and
    server_state_changed_at is that server's own timestamp. What no route
    reports is a health: last_health_check_status is always unknown, the
    contract's own default for the enum and the literal truth, because nothing
    here probes a backend — the Scaleway pack hands no balancer to the runtime,
    and the runtime's balancing half reports what it distributes, never what
    answered. A playbook asserting passed learns the limit from the value
    rather than from a 501. The two lists are empty because that is what a
    balancer here holds; CreateCertificate and CreateSubscriber stay
    declined, and their reasons say so.

    Driven by scw lb backend list-statistics, scw lb lb get-stats, scw lb certificate list and scw lb subscriber list in the conformance suite.

  • A Day-2 leg: a month of an operator's writes on one standing stack, each
    read back, then the platform proved working again
    (#673). mise run conformance:day2 brings examples/stacks/scaleway up on its own emulator
    and plays the catalogue in tools/conformance/day2lib.sh: twenty-four steps
    in do/undo pairs (rename, retag, protect, the address's tags and reverse, a
    public address created, attached, detached and deleted, a rule added and
    removed, the group's default opened and closed, the private network, the
    VPC, the balancer, the frontend, a backend server added and removed, the
    volume, a scratch volume and a snapshot created and deleted, a user data
    key, the placement group, the gateway, the ssh key, a NIC joined and left,
    and a reboot), fifty-odd writes, and after every one a read that has to
    say the change happened. A 200 is not evidence: the comparator refuses a
    wrong value and an absent path, the reboot's first read must not say
    running, and every wait is bounded and says how long it waited.

    Then the platform: under a runtime, platform-web-0's own table is compared
    with its capture from before the catalogue (#671's comparator, reused: a
    net-zero catalogue is exactly a comparison of shape after mutations), the
    emulator's own verification counters are read (#670), and the stack gate
    replays its verdicts on the same stack through FEINT_STACK_UP, fifty
    writes older. With no runtime the leg plays the control-plane half and
    says, by name, what it did not prove.

    What the leg found on its first runs, recorded rather than asserted, and
    none of it fixed here. With no runtime: PATCH /ips/{id} with "reverse": null answers 200 and the read still carries the name, so the undo uses
    ""; POST and DELETE on /backends/{id}/servers answer 501 where PUT
    serves the list. Under incus-ovn on 2026-09-04, playing the catalogue step
    by step on platform-web-0 and diffing its table after each: detaching a
    second public address took the first one's /32 off the guest (the re-plug
    repair restores the pinned address and not the external routes that stay),
    found only at the next join, whose replay put it back; and the reboot put
    the migrated /32 back on the routed eth0, from the guest's own netplan,
    which the layer's reboot comparison reported as restart(eth0) and its
    one repair did not undo. The third difference was the instrument's: the
    three private aggregates stood twice in the table, at the lease's metric
    and at the driver's, and both readers now collapse a route held at two
    metrics into one. The leg stays red under a runtime until the two defects
    are fixed, and says which line.

  • The runtime suite looks at the machine's own table across a restart, and
    names the line
    (#671). tools/conformance/functional.sh captures, from
    inside the machine and before anything is restarted, its addresses and every
    route with a next hop, normalised by two readers in functionallib.sh
    (connected routes, the link-local block, metrics, proto, the loopback and
    scope-link addresses never enter); after the provider's own reboot verb, and
    again after a stop and a start, it captures again and diffs. A line that
    changed fails the stack with both spellings printed, before and after: the
    cause where the effect is, instead of "nothing answers at 203.0.113.4:443"
    sixty seconds later at the far end. A capture that failed is "cannot look",
    never a side that compares equal to something.

    fnl_shape_reader_control runs before any verdict, like the other reader
    controls: it normalises two planted tables and requires the comparison to
    fail on a planted difference, the regression one line apart; and a test
    stubs the comparison to pass everything and requires the control to notice.
    Six execs on a leg of 590 s.

  • A reboot compares the shape after with the shape before, and needs no
    table of expected values for it
    (#669). Nothing moves in the control
    plane between the two, because the reboot holds the per-target lock from
    one end to the other; so what the machine carried while it was up is what
    it must carry once it is back, interface by interface (network, kind,
    addresses, rule sets, as sets), the default route by its door, the rest of
    the table as a set. The claims ride the same reading as the plan's own, so
    a reboot is read once and repaired once like any other boot. On the
    regression of 2026-09-04 this answers, at the moment of the reboot,
    restart(default route) want via 169.254.0.1 dev eth0, got via 10.77.0.1 dev eth1, where the runtime suite used to wait sixty seconds two steps
    later to say "nothing answers".

    A before that could not be read is said, in a WARN, and the reboot is
    judged on its plan alone rather than on a guess; a machine that was not up
    has no before. A poweroff then a poweron as two API actions is not a
    reboot: a NIC attached to a stopped server legitimately changes the shape,
    and there the derived claims judge.

  • A verdict somebody reads: the verification is published, and the runtime
    leg gates on it
    (#670). After every lifecycle door — a boot, a reboot, a
    hot join, a hot route, the late-address door of a virtual machine — what the
    runtime read back is published where a human looks and where a gate fails:
    an ERROR line per broken claim naming the claim, the wanted value and the
    one found; Runtime["verified"] on the resource, out of every client's view
    and on /_feint/state for whoever asks; and four counters on
    /_feint/health, verification.{held,broken,unreadable,repaired}, which
    moves the health schema to version 8.

    The API state does not move. A machine running with the wrong door is
    running and unreachable, and publishing it stopped would be the lie in the
    other direction. One bounded repair: a broken first reading replays the
    post-boot order once, every step of it idempotent, and reads again; the
    second reading is what is published, and repaired counts how often the
    first was wrong, because a repair that fires on every boot is a defect
    hidden behind a retry. An unreadable reading is never replayed onto, and the
    late-address door reads only while nothing readable was published, so a GET
    never execs into a verified machine.

    tools/conformance/guard.sh verification <endpoint>, called by every leg
    before its emulator stops: broken > 0 fails the leg naming each claim from
    the state, more unreadable than held fails it as a pass that measured its
    own blindness, absent counters fail it rather than read as zero, and a pass
    with no runtime is told so rather than judged.

  • The runtime reads back what a machine actually carries, and every claim
    of its plan is answered with three outcomes
    (#668). The Incus driver
    implements Observer: one query for the machine's own devices, one
    network get per emulated network it sits on, and three execs into the
    guest for the addresses, the table and, per door a claim asks about, ip route get <to> from <from>. The text of both ip implementations is parsed
    (iproute2 on Debian, Ubuntu and the RHEL family, busybox on Alpine), and what
    does not compare never enters the Shape: connected routes, the link-local
    block, metrics, proto, src, loopback, the operator's profile devices and
    their networks' gateways.

    held, broken, unreadable, never two: a read that failed answers
    unreadable on every claim, counted, and a runtime with no reading half is a
    fourth state, not asked, so "nobody looked" cannot pass as "nothing was
    wrong". The rule sets a machine wears are claimed on its filtered interfaces
    from the same fields the firewall step reads, bare on the networks a pack
    declared outside its groups' reach, and not at all on a host that withdrew
    the firewall capability.

    Two controls hold the instrument, both required by measurement-integrity:
    a divergence planted one digit off is reported as itself, and a failed read
    is neither held nor broken. And the regression of 2026-09-04 is reproduced
    through the fake runtime: the reply from the public address leaves through
    the private gateway, and a door claim is broken with both doors named. The
    door comparator exists; nothing derives a door claim yet (#672). Publishing
    the verdicts is #670, comparing a reboot's before and after is #669.

  • A plan that claims the default route twice is refused before the runtime
    is asked for anything
    (#667). Reconciler.Expect derives, from the
    declared plan, who claims which property: a public address owns the way out,
    Plan.Egress claims it, NoEgress claims it, and a property with more than
    one claimant refuses the boot with every claimant named, publishing the
    pack's own failed state the way a missing plan does. On the hot doors (an
    address routed, a network joined) the step that would execute the
    contradiction is refused instead, and the route the machine has is left
    alone.

    This is the layer's guard for #660: on 2026-09-04 the egress step replaced,
    with ip route replace, the default route the routed address had laid, every
    exec returned 0, and the machine answered nothing at its published address.
    The pack-side fix stands; this is what stops a fourth pack, or a change in
    this one, from writing the same contradiction and no test moving.

    What a consistent plan claims is returned as typed claims, each with its own
    tolerance (the mask only when the plan said one, any address of the block for
    a DHCP lease, the aggregates via a gateway of the plan), answered against a
    Shape with three outcomes: held, broken, unreadable. Which door a reply
    leaves by for a public address is a measurement and one shape has none
    (#672), so the derivation states no value for it: silence, not a guess. The
    reading that fills a Shape from the runtime is #668.

  • Flexible GPU is served, the whole family (#619): osc/Client.ReadFlexibleGpuCatalog,
    osc/Client.CreateFlexibleGpu, osc/Client.ReadFlexibleGpus,
    osc/Client.UpdateFlexibleGpu, osc/Client.DeleteFlexibleGpu,
    osc/Client.LinkFlexibleGpu, osc/Client.UnlinkFlexibleGpu.

    A published training course spends a whole page on this product, and a reader
    following it against the emulator met six 404s in a row. On the control plane
    an fGPU is the shape of a volume rather than of a machine: what a client
    observes is its model, its subregion and whether it is attached, and all three
    are records rather than hardware. That is the line the Load Balancer already
    sits on, where the product is served and GetLBStats answers what the emulator
    holds and no health, because nothing here measures a backend.

    The catalogue is a reading of a real account, eleven models field for field, so
    the create can refuse a model the cloud does not offer and a generation the
    model is not compatible with. The whole family or none of it: a catalogue whose
    items no create takes is the menu the ListVolumesTypes argument refuses.

    Dedicated hosts, VM templates and VM groups keep the refusal they had, with the
    argument narrowed to what it still covers.

  • The Load Balancer offer table is served (lb/v1/ZonedAPI.ListLBTypes,
    #658). Four types read off a real account, paged, and driven by scw lb lb-types list in the conformance suite.

    It was declined because "no measured client reads it before creating". A
    generated Ansible collection now does: it writes an _info module per list
    operation, and answering what is on offer is that module's whole purpose.

    The issue also reported the emulator's offer as closed and enforced. It was
    not: the refusals in the report came from one flexible IP reused across three
    creates, and with a fresh IP each time LB-INEXISTANT was accepted like the
    rest. Closing the offer was written and reverted, because the committed corpus
    replays CreateLB with a redacted type and a value check refuses the replay
    itself. lbtypes.go carries the whole argument, and it bounds every future
    refusal of a value: a pack may only refuse on what the anonymiser preserves.

  • serve --consistency eventual: a client's waiter finally has something to
    observe
    (#637, and the mechanism #124 asked for). Off by default. That governs the ordinary
    chains alone: since #654 a chain that is an action's only signal — a
    reboot's stopping, starting, running — is walked in both modes, or a
    reboot would be invisible to every client that did not ask for the flag.

    A reboot's target state is the state it started from, so state == "running"
    proves nothing to a client waiting on one. Without task support the only signal
    available is watching the machine leave its initial state — and this
    emulator answered running from the first read to the last. So poweron,
    poweroff and stop_in_place could all be exercised here, and reboot, the
    one action where waiting is subtle, could not be exercised at all. The author
    of #637 had to prove their own waiting guard by unit test, because running it
    against feint failed for a reason that had nothing to do with their client.

    With the mode on, a Scaleway server walks the states fr-par walks, measured
    2026-09-03 by polling a real DEV1-S through each action:

    action states answered, in order
    poweron starting, then running
    reboot stopping, starting, then running
    poweroff stopping, then stopped

    States advance on reads, never on a clock, and that is the design rather
    than a shortcut: the emulator's clock is injected, so a transient state timed
    on a wall clock would either never end under a frozen test clock or turn every
    suite into a wait. A four-second suite stays four seconds.

    Two consequences worth knowing. An action is not an observation — the read
    a lifecycle action makes in order to change a resource does not advance it, or
    the action would consume the first state it just pushed (that defect happened,
    and the reboot answered starting, running where the cloud answers stopping, starting, running). And a failed action walks no chain: a start that
    failed answers its failed state, because narrating a path towards a running
    it never reached is the plausible-wrong answer this project exists to avoid.

    The Outscale creating → available and in-queue → completed chains, and
    the 409 InvalidVolumeState refusal that was reverted for want of a
    reachable state, followed on the same mechanism (#124, below).

  • A project is a register now, and account/v3 can write to it (#391).
    account/v3/ProjectAPI.CreateProject, account/v3/ProjectAPI.UpdateProject
    and account/v3/ProjectAPI.DeleteProject are served — POST /account/v3/projects, PATCH and DELETE; a project that
    still holds a resource refuses its delete with 412 precondition_failed,
    resource_still_in_use, which is the cloud's own answer and the reason the
    route is worth serving rather than answering 204 over resources that outlive
    it.

    It exists because of the refusal below: refusing a create that names a project
    nobody holds, with no way to make one real, would have left the multi-project
    behaviour this emulator was built with no door at all. The door is the one the
    cloud has.

    --projects takes an identifier, name=<uuid>, and an entry with no =
    keeps deriving one from the name as before. This is what keeps #372's case
    alive: a stack carrying a production project id used to work because
    GetProject echoed anything, and it now works because the operator states it.
    A declaration is not an echo, which is the distinction #83 closed on all three
    packs.

Changed

  • Five Exoscale reads stop claiming no client drives them (#644, found by
    #631). Each carried a Route.Undriven reason written about exo, and four
    of them named their own expiry condition in the same breath: the Terraform
    provider does read by id, and it did not count because the build reaching
    this emulator was the patched fork docs/limits.md pinned, "a client this
    project patched is not the official client". The published v0.71.0 drives
    this pack since #644, so the condition was met rather than argued away, and
    get-block-storage-snapshot, get-elastic-ip, get-instance-pool,
    get-load-balancer and get-operation are driven. The last one is the
    interesting case: its reason ended on a sentence that turned out to be a
    prediction, that the route stays mounted because the day a client polls it
    must find it served rather than 404, and the provider polls every operation
    the SDK returns, terminal or not. docs/routes.md's "served, and driven by
    no client" list falls from 18 to 13 for this pack, and the reasons that stay
    are still about exo, still measured, and now the only client that reaches
    them.

    Nobody would have noticed: the register coverage/evidence.json had not been
    regenerated since #683, so it still carried figures from before that provider
    ran, and TestEveryUndrivenOperationSaysWhy had nothing to compare against.
    #631 regenerated it for its own two rows and the five stale reasons came out
    with it.

Fixed

  • A private machine behind a Public Gateway reaches the Internet under
    --vm incus-ovn
    (#647, #655). A machine with no public address had no
    outbound connectivity at all, and push_default_route = true on the gateway
    attachment installed no route: measured on a stack of 37 resources that
    Terraform applied, re-planned empty and destroyed cleanly, where apt then
    failed on every private machine with "Failed to update apt cache after 5
    retries". The shape only shows when a client provisions a machine rather
    than starting one, which is why a green apply said nothing about it.

    What the network announces is now the split the cloud makes: the OVN network
    announces where the private fleet is and stops announcing where the Internet
    is (ipv4.dhcp.gateway=none), and the way out is laid per machine by
    Plan.Egress for the machines the pack says are entitled to it. A server with
    no public address and no gateway reaches its VPC and nothing beyond it, which
    is what the real cloud does and what a DHCP-announced default route made
    impossible to express.

  • A poweron that failed no longer narrates starting (#738). Under
    --consistency eventual, a start whose runtime call failed answered
    [starting stopped stopped stopped] across four reads, with a task saying
    success: a client watching the action saw a boot that never happened.
    transitionTo refused any settled value outside running, stopped and
    stopped in place, and this pack's failed state is stopped — Scaleway
    declares no error state for a server — so the guard passed it. An action that
    settles where it started made no journey and now narrates none; a reboot,
    which legitimately returns to where it began, says so through
    transitionBackTo and keeps its chain. This makes true the sentence #637's
    entry above already claims.

    The test is the part worth reading. TestAFailedActionWalksNoChain listed
    the failed states failed, starting, unknown and "", none of which
    this layer produces, while its accepting half required a chain for
    from == settled == "stopped" — the failed poweron itself. It asserted the
    defect in one half and missed it in the other, over a population that
    excluded the only value that occurs. The accepting half now names journeys
    (stopped→running, running→stopped) rather than destinations against one
    origin, and TestAFailedPoweronWalksNoChain drives the whole path through
    HTTP, because the unit-level guard was already believed to hold.

  • A machine whose public address moved onto its private NIC carries the
    same shape before and after the reboot verb: the driver stops competing
    with systemd-networkd for the routed interface and takes it over
    (#742). The stack gate's reboot comparison (#671) reddened on
    platform-web-0 on the runner as on the author's station, and in two
    mirror shapes: once before: addr eth0 203.0.113.4/32, once after: route 0.0.0.0/0 via 169.254.0.1 dev eth0. Polled at half-second intervals across
    the reboot on 2026-09-07, the reboot itself was deterministic: netplan lays
    eth0 0.5 s after the container is back, the driver takes the stale
    address off 2.2 s after, the driver lays the default route 2.8 s after.
    What varied was the state before it. The migration of #548 edits the routed
    device, which re-plugs it, then repaired the interface at once and deleted
    the moved address right after, racing systemd-networkd's re-application of
    the guest's own netplan to the new link: networkd last, the machine kept
    the address on two interfaces; the driver last, it kept none on eth0 and
    no default route. An order was tried first and did not hold on the runner:
    polled at 100 ms across the hot attach, the re-plugged link answers
    Network File: n/a for 150 ms before networkd matches it, which reads
    exactly like "nobody manages this", and while networkd owns the interface's
    configuration every order the driver takes is a race. So the driver now
    tells networkd, in networkd's own configuration, to let go of a routed
    interface whose address moved: a drop-in beside the unit netplan rendered
    ([Link] Unmanaged=yes, the mechanism #702 already uses for the resolver),
    a reload, and a wait for the state networkd reports, (unmanaged), before
    the stale address is taken off and the routes laid — before the re-plug on
    the hot path, at the first boot after a cold migration on the restart path,
    and at every boot after that nobody but the driver writes the interface. A
    guest with no networkd is left alone. What is produced is deliberately
    unchanged, an addressless routed interface carrying the default route
    through 169.254.0.1; #742 changes who writes it, not what is written, and
    whether that is the right shape is #695's outbound half and the ADR of
    #726, not decided here. The defect is older than v0.12.1: today's gate driving a
    binary built from that tag reaches the step and fails it too, by more. The
    comparison that finds it is later than that tag, so no earlier release was
    measured on this step.

  • A machine holding a public address and a private network answers at its
    published address again under incus-ovn: the network names its own
    gateway as the lease's resolver
    (#697). runtime-proof was red four
    scheduled nights (09-04 to 09-07) on platform-web-0 of
    examples/stacks/scaleway, waiting: platform-web-0 answers on port 443,
    while the control leg at v0.12.1 passed each of them. The request landed
    (one connection in SYN_RECV inside the guest); the reply died on an
    on-link /32 towards 10.209.83.1, the uplink's address, which is what
    the station dials from and is not on the guest's segment. That route is
    the guest's own, RoutesToDNS= towards the DNS server its lease names,
    and since #693 the lease named none, so Incus named the uplink's address
    for it: naming nothing was not "no route". Three values measured, two
    rejected: RoutesToDNS=no in the guest's drop-in changes nothing, the
    lease lays the route before the drop-in lands; a public resolver repairs
    the reply and moves the dead /32 to 1.1.1.1, which is #684 back. The
    gateway is on the segment by construction: routes naming the uplink 0,
    no dead /32 elsewhere, the reply via <gateway>, and the station reaches
    the published address. Nothing answers DNS at the gateway, and nothing has
    to: the name server a machine uses comes from the drop-in and resolvectl
    (#694, #696), and the lease's value decides only which route is laid.
    TestAnOVNNetworkLaysNoRouteTowardsItsResolver holds the invariant rather
    than a value — every name server the lease names is on the segment, and it
    names one. The outbound half of #695 is not this: a machine holding a
    public address still has no default route under OVN.

  • osc/Client.ReadVms refuses a VmIds value that is not an identifier
    (#396), as the real account does and as no other read does. The recording
    of 2026-08-21 (corpus/outscale/oapi-cli-refusals.jsonl) sent
    ["not-an-identifier"] to seventeen reads: ReadVms answered 400 with
    Errors [{Code 4104, Type InvalidParameterValue, Details "the provided value does not respect the expected ID prefix"}], and the sixteen others
    answered 200 and an empty list. This emulator answered 200 on all
    seventeen. It now refuses on ReadVms alone, with the recorded code, and a
    test sends the same value to every declared identifier filter of every
    other read, ReadVmsState's VmIds included, and holds their 200: the
    asymmetry is upstream's, and reproducing it on one read is the fix, on
    seventeen it would be sixteen new divergences. The two corpus exemptions
    naming this issue are gone.

  • An Exoscale validation refusal carries its errors array (#397).
    exoscale/v2.create-private-network and exoscale/v2.update-private-network
    refused a range declared backwards, or half declared, or a create with no
    name, with {"message": …} alone, where the recording of 2026-08-21
    (corpus/exoscale/exo-refusals.jsonl, a real ch-gva-2 account) shows
    {"message": …, "errors": [{"message": …}]} on both recorded 400s. The
    same recording shows five 404s and one 409 without the array, so it is the
    shape of a validation failure and not of an error, and it is written on
    those refusals alone: a 404 still carries the message and nothing else,
    and a test holds that half. The corpus exemption naming this issue is gone,
    and feint corpus --check now compares the two recorded exchanges rather
    than excusing them.

  • osc/Client.ReadImages and osc/Client.ReadSnapshots serve
    Filters.AccountAliases
    (#700), the filter the contract's own ReadImages
    example uses. Both reads refused it with the 4001 that names what they
    serve, measured on 2026-09-05 through osc-sdk-python 0.42.0 and reproduced
    on 2026-09-06 before the fix. It selects on the owner's alias, and the owner's alias is
    now on every object the owner holds: the catalogue's images carried
    AccountAlias since #95 and a registered image or a snapshot of the same
    AccountId carried none, so the filter would have split one owner in two.
    The Snapshot schema declares the field beside AccountId, and the real
    cloud puts it on an image whose owner has an alias (shapes/outscale.json).
    What stays, and docs/limits.md records: the catalogue is the emulated
    account's, by decision, so AccountAliases: [Outscale] answers an empty
    list here rather than a catalogue relabelled with an owner id the corpus
    redacts, and self is not an alias this API was measured accepting.

  • An Exoscale elastic IP reads its labels back (#703).
    exoscale/v2.create-elastic-ip and exoscale/v2.update-elastic-ip declared
    labels in their request structs and stored nothing, and no view served the
    field, so exoscale/v2.get-elastic-ip and exoscale/v2.list-elastic-ips
    answered a labelled address without labels while its description, from the
    same body, came back fine, and the second plan of a stack that labels its
    address was never empty: measured on 2026-09-05 with the Python SDK, and
    under provider 0.71.0 as Plan: 0 to add, 1 to change on the one resource
    of twenty-three carrying labels.
    Stored on create, replaced on update when the client sends the field and
    left alone when it does not, cleared by an empty map, served when non-empty
    and absent otherwise, the way the private network already did. The same
    pass narrowed exoscale/v2.reset-elastic-ip-field to what the API description declares,
    description alone: the pack accepted labels and healthcheck there,
    neither in the contract's enum, and the first answered 200 while clearing a
    field it had never stored.

  • feint env exoscale tells a Terraform user what the emulator does today
    (#701). The note on stderr still said, a day after #644 had lifted the
    refusal, that the provider honours EXOSCALE_API_ENDPOINT for half of itself
    and that Terraform must not be pointed here — the first thing a Terraform
    user reads, telling them the opposite of what the run then did. It now says
    the published provider drives this pack from v0.71.0
    (exoscale/terraform-provider-exoscale#576), to pin at least that version,
    and that an older one is refused by user agent, naming the floor; it reads
    the floor from the constant the refusal reads, so the two cannot drift apart
    again. The same stale sentence was measured in four documents and one guard,
    and all say the same thing now: docs/confidence.md answered no to a
    Terraform run against Exoscale, docs/environment.md and examples/stacks/
    said feint up refuses the engine at a doorstep #644 had removed, and
    emulator.env still refused FEINT_EXOSCALE_ALLOW_TERRAFORM by name — a
    variable nothing reads since #644, whose refusal #644 itself recorded as
    gone. It is gone now.

  • reverse: null clears a flexible IP's reverse, and reads back null (#676).
    The SDK sends the reverse as a NullableStringValue whose JSON is a literal
    null to clear, and the emulator read it into a *string, where a null and
    an absent field are the same thing: the write answered 200 and the reverse
    stayed, the shape of #654 one product over, found by the Day-2 leg's
    read-after-write. Measured on a real account on 2026-09-04: a fresh address
    reads null, {"reverse": null} answers 200 and reads null, and so does
    {"reverse": ""}. What the account also answers, and this emulator does
    not: a reverse that does not resolve is refused with a constraint on
    reverse (docs/limits.md keeps that divergence, since a stack's reverses
    point at documentation addresses nothing resolves).

  • A re-plug of an OVN NIC gives the guest back every public address the
    device still routes to it
    (#675). Attaching a second public address to a
    server holding one, or detaching it, re-plugs the NIC — the route keys are
    not live-updatable — and the repair that follows put back the pinned
    private address alone: measured on 2026-09-04 under incus-ovn, on the
    device and in the guest at each step, attaching 203.0.113.5 to a server
    holding 203.0.113.3 left the device routing both and the guest carrying
    the new one alone, and detaching it left the guest with no public address
    at all. The next join replayed the addresses and repaired it in silence,
    which repaired: 1 on /_feint/health was the only trace of. The repair
    now reads the device after the edit and re-adds every /32 of
    ipv4.routes.external: an attach reads the merged list, a detach the kept
    one. The routed NIC's path was not the defect and is held by a test of its
    own, so nobody reasons about it again.

  • A restart reconciles the routed NIC to its device, and the guest's own
    config keeps laying the launch shape
    (#674). A server created with a
    public address and no network boots on a routed NIC (#202), declared
    statically in the network-config the launch hands cloud-init — the address
    as a /32, the on-link default route through 169.254.0.1. cloud-init
    renders it once, at the first boot, into the guest's own
    /etc/netplan/50-cloud-init.yaml, and that file outlives every edit the
    driver makes to the device afterwards: measured on 2026-09-04 under
    incus-ovn, a server whose private NIC arrived hot and took the address
    with it (#548) still declared 203.0.113.3/32 on eth0 in the guest while
    the device pinned nothing, and an API reboot put the address back on eth0
    beside the one the driver had laid on eth1. Two writers of one interface,
    now in a fixed order: the guest's config lays it at every boot — and must,
    measured the same day: a first attempt that took eth0 out of that config
    left systemd-networkd managing nothing, systemd-networkd-wait-online
    waiting to its 120 s ceiling and sshd starting 90 s after the ssh suite had
    given up, where main logs in within twenty seconds — and the restart path
    then waits, bounded, for the guest to have laid the interface, takes off
    every address the device neither pins nor routes, and puts back what it
    does, the link-local next hop and the default route through it.

  • An OVN network announces a public resolver, never the uplink's address
    (#660). Incus announces the uplink's own address as the DNS server, and that
    address is also the source the station dials a public address from once the
    address rides an OVN NIC (#548); inside the guest, systemd-networkd's
    RoutesToDNS= lays an on-link /32 towards every DNS server the lease
    names, more specific than the aggregates towards the router, and the reply
    to the station ARPs on the switch for an address nothing there carries.
    Measured 2026-09-04 under incus-ovn: platform-web-0 served 443 inside
    and answered nothing at 203.0.113.3:443; ip route del 10.209.83.1 dev eth1 turned the dial from 000 into 200, ip route add turned it back. This entry was written when the network announced a public
    resolver; it moved twice afterwards inside this same release. #684 and
    #693 took the announcement out, because a public resolver is off the
    subnet and the on-link /32 towards it took the machine's way out with it.
    Announcing nothing then let Incus fall back to the host's resolvers — the
    uplink's own address — which is the dead route this entry set out to
    remove, and #697 settled it by announcing the segment's own gateway: on
    the subnet, so the route it lays is alive. The resolver itself reaches the
    guest through resolvectl and a drop-in (feint serve --resolver,
    default 1.1.1.1), and the uplink's address is refused whatever the field
    says. RoutesToDNS= is the guest's ordinary mechanism, not a defect worked
    around: the announcement removes the collision that made it harmful. Not
    established, measured the same day: that a machine with a way out resolves
    through the announced resolver — the announcement was on the wire and the
    guest's resolved held no server on any link, resolving once one was set
    by hand; a defect of its own, followed apart. Left open on #660: a machine
    with a public address and a private NIC still comes up, in some orders,
    with no default route at all (overlaps #678). feint serve --resolver and
    feint start --resolver are new flags: CLI surface version 24.

  • A snapshot of a disk nothing was ever attached to was accepted, and the
    published example stack depended on it
    (#650, #646).

    The cloud refuses it: cannot create a RO disk from an empty disk. So the
    golden-image pattern the Scaleway example stack demonstrates — a volume, a
    snapshot of it, an image cut from the snapshot — could not apply against the
    real Scaleway API
    , and the reporter found out by running their copy of it
    there.

    Where the line falls was measured three ways, because it is not where it first
    looks:

    subject fr-par
    a volume created and never attached 400, cannot create a RO disk from an empty disk
    the root disk of a server that never started 201
    a disk detached from a deleted server 201

    So the question is not whether the machine ran, and not whether the disk is
    attached now. It is whether the disk was ever anybody's — which is why the
    mark is set on attach and never cleared on detach.

    The refusal carries a details entry with a reason and a help message and no
    argument_name at all
    , so ArgumentError omits that field when it is empty
    rather than sending "".

    The example stack now builds its golden image from a disk a builder machine
    carries
    , which is what a client has to do against the cloud, and the pattern
    it teaches survives contact.

  • The Scaleway example stack said one thing and did another, and was not
    routable as written
    (#646). Its vpc_route was commented "reach the
    management range through the admin network" and pointed at app — and the
    code was the half that was right: a route's next hop is a private network of
    its own VPC, and admin is in the other one.

    Underneath that, the topology cannot carry what the header advertises: two VPCs
    that share nothing cannot reach each other, so the bastion described as "the
    only public door" opens onto nothing. Under mode: off nothing boots and the
    difference never shows — the reporter met it by running Ansible through that
    bastion under --vm incus-ovn, and spent a while suspecting their own
    ProxyCommand.

    The header says so now, in the file a reader copies rather than only in the
    feint.yaml beside it. Making the stack routable — one VPC, several private
    networks — is deliberately not done here: it would trade the isolation this
    stack demonstrates for reachability it does not, and that is a choice about
    what the example is for.

  • Two inputs the cloud refuses and this emulator accepted, and one it accepts
    that this emulator refused
    (#648, reported with a differential run: the same
    Terraform stack against main and against api.scaleway.com).

    The reported pair, both in the direction that costs — a client green here
    learns nothing about the real cloud:

    input fr-par before
    an ACL rule whose dst_port_high is below dst_port_low 400, and it names both bounds 200
    instance/v1 CreateSnapshot on a block (SBS) volume 404 not_found, resource: instance_volume 201

    The first is not an exotic input: it is what the Terraform provider sends for
    the commonest rule there is
    , one port opened, since a rule that names only
    dst_port_low leaves the high bound at zero. Three rules out of three in the
    reporter's stack were that shape.

    The second is the golden-image pattern. A server's root disk lives in block
    since #365 — which is what the cloud gives a DEV1-S — and a block volume is not
    an instance_volume, so instance/v1 does not snapshot it. This pack resolved
    both products there on purpose (#571) and the measurement reverses that: the
    reason it was written has been served differently since, and the conformance
    suite had already stopped taking that route.

    Measuring it turned up two more divergences on the same route, and one goes
    the other way:

    • argument_name is rules[0]dst_port_high — no dot before the index, none
      before the field. This pack wrote rules.0.dst_port_high, wrong for every
      rule error rather than for one of them. The reason differs per field too:
      unknown for action and source, constraint for the ports.
    • an unknown protocol is normalised to ANY, not refused. NONSENSE
      answers 200 and reads back as ANY. This pack refused it — the rarer
      direction, stricter than the cloud, and a client that would have worked
      against fr-par met a 400 here. Being stricter is still being different.

    One detail is copied rather than tidied because nobody would invent it: in the
    same body, the high bound's min is the string "443" and the low bound's
    is the number 0.

  • The organization filter was ignored, so an organization that owns nothing
    here answered the whole fleet
    (#638). Seven of ListServers' eight declared
    filters partitioned the set exactly, project included and with the same
    shape; only this one had no effect.

    The failure mode is what makes it worth a line: an ignored filter does not
    answer an error and does not answer nothing. It answers a well-shaped,
    plausible, larger
    page, and the client cannot tell. It is #630's defect
    mirrored — match everything where that one was match nothing.

    Measured on fr-par, 2026-09-02, with one volume on the account:

    request fr-par answers
    ?organization=<the account's own> the volume, visible
    ?organization=<a ghost uuid> 200, empty
    ?organization=not-a-uuid 400 invalid_arguments, argument_name: organization, "not-a-uuid is not a valid UUID."

    Both facts were missing: the filter partitions, and a value that is not a UUID
    is refused rather than accepted and ignored.

    Why this was left alone until now, and what changed. The reason is written
    in listSSHKeys and it was a good one: scw names its configured organization
    on every list, nothing obliges that configuration to spell this emulator's
    constant, and comparing once told the CLI that the key it had just created did
    not exist. #391 settled the same argument for projects — the cloud filters, and
    a client configured from feint env is clear of it. feint env scaleway
    publishes SCW_DEFAULT_ORGANIZATION_ID as this emulator's own constant, and the
    conformance suite's fake-credentials.env carries the same value.

  • A package install ran under the cap made for a control command, and the
    nightly runtime proof died on it
    (#641). Every incus call went through one
    120-second cap — the right bound for info, start or network create, where
    a control plane slower than that is broken rather than busy. incus exec <builder> -- dnf install -y -q openssh-server went through it too, and on
    2026-09-03 it was killed at exactly that limit while building almalinux/9,
    taking the whole scheduled run with it. The night before, the same command had
    finished just under.

    A step inside a build instance now runs under its own cap, ten minutes, and the
    build as a whole is still bounded by the twenty minutes that stop a wedged
    download from holding the lock for ever. An operator who set Timeout because
    their station is slow gets both scaled, since a station that needs a longer
    control cap needs a longer install cap by the same measure.

    The cap is asserted rather than described: the deadline is set before the
    runner seam as well as before the real binary, so a test reads
    ctx.Deadline() and says which cap a call ran under. Both halves are held —
    a guard that gave everything ten minutes would break what the control cap is
    for.

  • Two refusals answered the wrong status, and a client branches on exactly
    that
    (#394). A 404 says "create the parent first", a 400 says "your body is
    wrong and the parent is beside the point", a 403 says "you are not allowed
    here", and Terraform retries none of the three the same way.

    vpc/v2 CreateRoute resolved the VPC before it read the body. Measured on
    fr-par, 2026-09-02, with a vpc_id that names nothing:

    request fr-par answers
    no next hop at all 400, argument_name: route, requires_one_of: {"fields": "nexthop_vpc_connector_id, nexthop_private_network_id"}
    with a next hop 404 resource is not found
    no vpc_id at all 400, argument_name: vpc_id, help_message: "uuid: {}"

    So the cloud is not validating the body first as a habit: its generated layer
    enforces what the document declares — a required field, a UUID shape, a
    requires_one_of — and only then does a handler resolve anything. The same
    request answers 400 or 404 depending on which of those two walls it meets, and
    this emulator answered 404 to all three.

    lb/v1 CreateRoute answered 404 for a frontend that does not exist, where
    fr-par answers 403 — and that is this route's own answer rather than the
    product's: GET /frontends/{id} and GET /lbs/{id} both answer 404 on both
    sides. The status is now the cloud's.

    One divergence stays, and it is written down rather than left implicit.
    lb/v1 sends no scw error envelope on any of those three refusals: no
    type, no details. This emulator sends one, because
    contracts/scaleway.json declares a single errorSchema for the whole
    document — scw.ResponseError, required: ["type"], extracted from the SDK —
    and internal/probe validates every refusal against it. Lifting that means an
    errorSchema per product, arbitrated by a recording rather than a document.
    docs/limits.md carries it.

  • A create naming a project that does not exist was accepted here and refused
    by the cloud, across seven products
    (#391). Recorded in #390 and re-read
    field by field on 2026-09-02, because the corpus redacts values and the words
    are what a client branches on:

    request fr-par answers
    instance/v1 create (server, IP, security group, placement group) 403 permissions_denied, details: [{resource: project, action: read}]
    lb/v1 create 403, details: [{resource: loadbalancer, action: write}]
    block/v1 CreateVolume 403, details: [{resource: volume, action: write}]
    iam/v1alpha1 CreateSSHKey 403, details: [{resource: ssh_key, action: create}]
    vpc/v2, vpcgw/v2, ipam/v1 creates 404 not_found, resource: project

    Two shapes, not seven, and what varies inside the 403 is a field rather than a
    dialect: only instance/v1 names the project at all. The others name their
    own product and the action refused, which is a different sentence.

    GET /account/v3/projects/{id} stopped echoing, and that is a documented
    decision reversed rather than an oversight fixed. It answered for any
    identifier so that a stack carrying a production project id would not die on
    the one call that has nothing to do with what it is testing (#372). An echoing
    read beside a refusing create is worse than either alone — the client resolves
    a project with 200 and is then refused every resource under it, which is the
    disagreement #369 had between its own create and list, one product out. The
    reason the echo existed did not disappear: it moved to --projects name=<id>.

    Two ordering defects surfaced with it, and the corpus named the first one.
    vpcgw/v2 CreateGateway validated the gateway type before the project and
    answered 400 where the recording says 404; ipam/v1 BookIP validated its
    source first and answered 400 where fr-par answers 404 on the project. Both
    now ask about the project first, which is the order the cloud answers in.

    And the replay had to learn something to be able to judge any of this. On
    the account these corpora were recorded from, the organization and the default
    project carry the same UUID, so the redaction gives both
    00000000-…-000000000001, and the replay was sending the emulator's
    organization where a project belonged. It now seeds replay.Options.Bind
    per file — the seam that already existed for standing one account in for
    another — and only for a file whose recorded project the cloud actually
    accepted. scw-refusals.jsonl gets no stand-in, so its ghost project goes out
    as itself and is refused, which is the one thing the gate has to be able to
    judge here.

    The 36 acceptance entries naming this issue are deleted, and one more went with
    them: ipam/v1/API.ListIPs had stopped excusing anything the moment #369
    landed, and this replay is what reached it.

  • A server created under a named project was invisible to the list that
    followed it
    (#369). createServer honoured the project a request named,
    and every unfiltered list scoped itself to the default project, so the create
    and the list disagreed: a client that creates then lists — which is every
    client — read an empty fleet. The comment above scopeOf already said the
    right thing, "Neither means the token's default project", three lines above
    the code doing the opposite.

    The issue offered two coherent answers and the cloud settled which one it
    takes. Measured on fr-par, 2026-09-02, with a second project made for the
    occasion and deleted after:

    request the other project's volume
    GET /volumes (no filter) visible
    GET /volumes?project=<the default> hidden
    GET /volumes?organization=<the org> visible

    So the create is authoritative: naming a project makes it real, and an
    unfiltered list answers the account. The isolation the old behaviour was
    defending is the filtered read, and it is untouched — a list naming a
    project still answers that project alone.

    The acceptance entry on instance/v1/API.ListServers is deleted, and the gate
    asked for it in those words: "excused nothing this run: either it is fixed and
    the entry goes, or the corpus no longer carries it"
    .

  • The cloud retired b_ssd at creation and this emulator was still minting
    it
    (#393). Every route that made a volume wrote that type: POST /volumes
    defaulted to it when the request named none, a server's root_volume was
    forced to it whatever local type was asked for, an update that added a disk
    defaulted to it, the catalogue images advertised it, and a snapshot with no
    readable type fell back to it. fr-par refuses it on both creation routes.

    Measured against a real account on 2026-09-02, and the two routes do not share
    a wording — which is why both are transcribed rather than one being reused:

    request fr-par answers
    POST /volumes no volume_type 400 required — required key not provided
    POST /volumes b_ssd 400 constraint — b_ssd volumes are no longer supported. Use Scaleway Block Storage (SBS) volumes instead…
    POST /volumes sbs_volume 400 constraint — not a valid value (it is block/v1's product)
    POST /volumes l_ssd, scratch 201
    POST /servers volumes.0.volume_type: b_ssd 400 constraint on volumes.0.volume_type — Create volumes with volume_type=sbs_volume instead
    POST /servers l_ssd 201, and the volume comes back l_ssd — honoured, not replaced
    POST /servers scratch 400 constraint on image — Cannot use an image with a scratch volume

    This is the drift the surface scan cannot see: no Go signature changed, on
    either operation. Only a recording of the cloud names it, which is the whole
    argument for keeping one. The four acceptance entries that carried the
    divergence in corpus/accepted.json are deleted, and feint corpus --check
    compares those exchanges for real now.

    A limit that moved with it: the local types are no longer overridden. A
    create naming l_ssd gets an l_ssd, where it used to get a b_ssd under a
    comment explaining why. The reason that override existed is unchanged and
    still holds the catalogue in place — volumes_constraint.min_size stays at 0
    so the CLI's sum of local volumes cannot refuse the creation it just asked
    for. docs/limits.md carries the corrected paragraph.

    And one number moved with the type. The catalogue image's root volume is
    l_ssd now (measured: a DEV1-S created on fr-par with image=ubuntu_jammy
    answers image.root_volume.volume_type: l_ssd, size: 10000000000), which
    puts it inside the arithmetic scw runs over local disks. At the 20 GB this
    emulator advertised, every STARDUST1-S create was refused with "image …
    requires 20 GB on root volume, but root volume is constrained between 0 B and
    10 GB"
    . rootVolumeSize is 10 GB, the same as the cloud's, and the image
    endpoint reads that constant instead of repeating the number. The conformance
    suite caught this and no unit test could have: the arithmetic lives in the
    client.

    What it also settles: the decline on instance/v1/API.ListVolumesTypes
    rested on a sentence that has expired — this emulator makes neither of the
    two types /products/volumes lists
    . It makes exactly those two now. The
    decline stands on its other condition alone, and says so: no recording of that
    route exists in corpus/, and it answers a table of per-type constraints that
    rule 4 forbids inventing.

  • A boolean query filter read one spelling and silently emptied the listing for
    the others
    (#630). attached=true filtered correctly; attached=True — what
    the official Python SDK sends, because requests renders a Python bool that
    way — matched nothing and answered a well-shaped empty page. So
    IpamV1API.list_i_ps_all(attached=True), the documented way to ask that
    question from Python, returned an empty inventory against this emulator and the
    attached addresses against the real API.

    Measured on fr-par, 2026-09-01: the real API accepts true, True, 1 and
    the rest of what strconv.ParseBool takes, because it is a Go service using
    that function. This pack now parses with the same one.

    And an unparseable value is refused rather than swallowed. It used to be
    read as false, which says the fleet is empty — a different and wrong statement
    from the question was malformed. The two error dialects are the cloud's own
    and are not one: instance/v1 answers its typed invalid_arguments, while
    ipam/v1, vpc/v2, vpc-gw/v2, lb/v1, block/v1 and iam/v1alpha1 answer
    strconv's error verbatim. Copying one onto the other would have been inventing
    a format.

    The fix covers every boolean the pack reads from a query, not only the one
    reported: eleven filters and, more seriously, two action flags. A client
    sending release_ip=True or delete_ip=True kept its address and was told
    nothing. Those two are also parsed before the object is looked up, which is
    the order fr-par answers in — a 400 sends a client to its own request, a 404
    sends it hunting for a resource.

    Reported by a consumer building a dynamic Ansible inventory for Scaleway.

Added

  • instance/v1/API.UpdatePrivateNIC is served, and the refusal it replaces is
    named here because a withdrawn refusal is the change a consumer builds around
    (#624). PATCH /instance/v1/zones/{zone}/servers/{server_id}/private_nics/{id}
    writes the tags a NIC carries; a request naming none changes nothing, which is
    what makes a read-compare-write module idempotent.

    The decline had said the pack "stores no tag on a private NIC, so it would
    answer success over a field nothing reads back". Both halves had stopped being
    true: createPrivateNIC has stored the tags all along, listPrivateNICs
    filters on them, and the read carries them on all three doors. A decline is a
    decision and may be revisited; a decline's reason is a claim about the code,
    and this one described a past state.

    It also makes an assertion real that the code had been waiting for.
    modification_date is served from the resource's own updated time, and the
    comment beside it said no route modified a NIC, so the two dates carried one
    instant and a test requiring them to differ could not have failed. This route
    is what makes them differ.

    Reported by a consumer generating an Ansible Day-2 collection from the Scaleway
    OpenAPI documents.

  • instance/v1/API.GetDashboard and instance/v1/API.GetServerCompatibleTypes are
    served
    , and the refusals they replace are named here because a withdrawn
    refusal is the change a consumer builds around (#626).

    Both had been declined under "capacity and quotas are the provider's fleet,
    and a local emulator that answered would be inventing headroom a client could
    plan against"
    . A live read of fr-par-1 on 2026-09-01 withdrew that sentence
    from both: compatible-types answers a plain list of type names with no
    headroom in it, so it is a property of the catalogue rather than of the fleet;
    and every key the dashboard returns names a family this pack serves, so the
    "unemulated remainder" the decline invoked is empty. The two volume-type
    counters this emulator never fills are zero because it makes none, which is
    true of it rather than short of anything.

    GetServerTypesAvailability keeps the sentence, and keeps it rightly: fr-par-1
    answered shortage for every type, and any value served here would be a claim
    about Scaleway's fleet.

    Neither has a scw verb, so both declare why no client drives them rather than
    pretending one does.

Changed

  • The upstream drift scan runs every night and reports through the
    night-report mechanism
    (#705). .github/workflows/drift.yml ran on Monday
    alone, and a scan that failed for a reason other than "the surface moved" (a
    clone that would not fetch, a build that broke, a push that was refused) left
    a job log and nothing else, the failure mode #501/#502 measured on the
    runtime proof. The scan is on a nightly cron now, and a night job adopts
    night-report.yml: a night where the scan could not conclude opens one
    scheduled-red issue, updated, closed by the first green night. The two
    outcomes stay apart, and the tests hold that rather than a comment: the
    surface moving is the pull request, as always, never a red night (the
    Coverage report step's continue-on-error keeps the job green), and the
    pull request stays conditional on a real diff, because a pull request every
    night would teach everyone to close them unread. Measured on the five
    scheduled runs since 2026-08-03: all green, none moved the surface, none went
    red, so the two nights that matter are derived from the real payload of
    2026-08-31 and say so.

  • The Outscale catalogue's images are ami-fe1a7001, ami-fe1a7002 and
    ami-fe1a7003, no longer ami-00000001..3
    (#395); its seven net access
    point services are pl-fe1a7001..7, no longer pl-00000001..7; and the
    Exoscale default security group is fe1a7000-0000-4000-8000-000000000001,
    no longer 00000000-0000-4000-8000-000000000001. All three lived in the
    namespace the
    corpus sanitiser mints (feint transcript --sanitise: prefixed identifiers
    as a counter in eight hexadecimal digits, UUIDs under
    00000000-0000-4000-8000-), so a sanitised recording could name a fixture of
    this emulator where it meant an object of the account: measured on
    corpus/outscale/oapi-cli-refusals.jsonl, where osc/Client.DeleteImage
    and osc/Client.UpdateImage on an image the cloud refused with 400 replayed
    as ami-00000002, found the catalogue, and answered 409 "belongs to the
    emulated catalogue". The two corpus exemptions carrying that are gone, and
    feint corpus --check compares the exchanges. A test in internal/cli
    reads every string literal of every pack's source with the sanitiser's own
    recogniser, so no pack can move a fixture back in. A client that hardcoded
    ami-00000001 changes one value; the README, the quickstart and every
    conformance fixture did.

  • instance/v1/API.ListVolumesTypes stays declined and its reason is replaced
    (#625). The old one said a type list "would describe capabilities and
    constraints nothing here can honour", which could not be what separates it from
    ListServersTypes — that sibling is served and answers capabilities,
    constraints and prices.

    What separates them is measured. The served catalogue names types this emulator
    creates, and its values come from a recording of a real account. A live read of
    /products/volumes on fr-par-1 answers exactly two types, l_ssd and
    scratch, and this emulator makes exactly those two since #393 — the
    sentence this decline first rested on has expired, and the entry for #393
    above says so. What holds it now is its other condition alone: no
    recording of /products/volumes exists in corpus/, and the route
    answers a table of per-type constraints that rule 4 forbids inventing.

  • instance/v1/API.ExportSnapshot stays declined (#627). Answering a
    202-shaped acknowledgement without writing an object anywhere would be the
    emulator saying something started that did not, which is the one thing it
    exists not to do. The reason is infrastructural and measured in
    docs/limits.md.

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) Exoscale, Outscale, Scaleway
OpenTofu 1.12.5 .github/workflows/conformance.yml (TOFU_VERSION) Exoscale, 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 >= 0.71.0 constraint: resolved fresh on each run, so the version that answered is not knowable here yes
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.

Every stack under examples/stacks/ is applied on every pull request.

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.