Skip to content

fix(outscale, machine, emulator): a filter it cannot read is refused, a decline is a warning, and a refusal carries a marker (#566, #474, #477, #505) - #580

Merged
stephrobert merged 6 commits into
mainfrom
fix/566-477-474-505-how-a-refusal-is-spelled
Aug 28, 2026
Merged

fix(outscale, machine, emulator): a filter it cannot read is refused, a decline is a warning, and a refusal carries a marker (#566, #474, #477, #505)#580
stephrobert merged 6 commits into
mainfrom
fix/566-477-474-505-how-a-refusal-is-spelled

Conversation

@stephrobert

Copy link
Copy Markdown
Owner

Closes #566. Closes #477. Closes #474. Closes #505.

Four issues about how a refusal is spelled. Three share one shape — three
answers folded into two, and the one that disappears is always the one that
produces a silent success
. The fourth is a client's defect and closes as
documentation, with the remedy that would have been a fix disproved.

#566 — a filter it cannot read is now refused, and the issue understated it

filters.go has stated the rule in its own preamble for a year: "a filter is
either applied or refused, never ignored."
The code beneath it ignored four.

Measured against main@2879888, and wider than the issue said:

VolumeSizes [40]      -> 200, both volumes    (the type the API declares)
VolumeSizes ["40"]    -> 200, the 40 GiB one  (a type it does not)
VolumeIds   "vol-x"   -> 200, both volumes    (a string where an array goes)
Progresses  [7]       -> 200, four snapshots at Progress 100
AccountIds  ["…000"]  -> 200, four snapshots at AccountId …001

The only shape this pack could read was the one the API does not declare. A
client conforming to the contract was filtered to nothing; a client sending the
wrong type was served correctly.

And AccountIds is a plain array-of-strings filter — perfectly decodable —
that nothing compared: snapshotFilters named three filters snapshotMatches
never mentioned, so no decoder, no kind check and no contract check could ever
have caught it. A different mechanism, invisible to the issue's framing.

After: [40] selects one, ["40"] and the bare string are 400s naming the
filter and its declared shape, Progresses and AccountIds discriminate. octl
drives all of it, because it sends integers.

A fifth defect fell out of the new control: ReadVms served VmStates,
which belongs to FiltersVmsState. It refused the real VmStateNames and
applied an invented name — and TestTheServedFiltersFilter drove the invented
one, the emulator proving itself against itself. Fixed; nothing outside the
pack's own tests used it.

#474 — a decline is a WARN, and 47 of 48 sites were already right

Fourteen level=ERROR lines over fifteen stack replays were all one deliberate,
documented refusal. The instinct is to change the level; the work was to inventory
the 48 error sites, find that 47 said something true, and correct the one that
called a decline a failure.

The rule is now written where it is applied: an ERROR is something this emulator
did not do that it was built to do; a WARN is something it deliberately declines
and documents, where the API answer stays honest.
An ERROR one learns to ignore
disarms the reading of a log exactly as a gate one learns to skip disarms the
hooks.

#477 — the marker moves out of band, because the obvious remedy fails a real client

A route feint does not serve answered a bare 404, which the official client reads
as an ordinary empty answer. Three refusals sat inside a fully green stack.

The issue's preferred remedy — answer 501, the shape not_emulated this
emulator already has — is refused, with the measurement: 501 costs exo
nothing in latency (22/21/19 ms against a 19 ms baseline) but fails
exo compute instance create
, which reads GET /v2/reverse-dns/instance/{id}
after every create and treats anything but 404 as fatal. The exo-cli leg died at
"instance create rejected" under 501 and passes under 404.

So no status and no body can carry the marker there. It moved to
X-Feint-Not-Emulated, set once in handleUnrouted for all three packs, beside
the existing X-Feint-Fault and X-Feint-Probe.

This is the polar star arbitrating in the direction people forget: a louder
refusal that breaks a client the real cloud would have served is a defect too,
and symmetrical.

#505 — documentation, and remedy (1) disproved

scw 2.56.3 panics (recovered, rc=0) on every successful lb acl delete. The
only remedy that would have been a real fix — the emulator answering something
that does not trigger the faulty assertion — is false, read and then measured:
ZonedAPI.DeleteACL decodes the body into nil, so nothing the emulator answers
reaches the faulty path; Run returns success unconditionally on a nil error;
and the interceptor asserts *ZonedAPIDeleteCertificateRequest on a
*ZonedAPIDeleteACLRequest, so getACL is nil everywhere. The only lever is
failing a delete that worked — measured with PUT /_feint/faults: no panic, and
the ACL survives
.

Filtering the line was refused too: it would hide a real upstream defect. What
ships is the honest half — scw-cli.sh now reads the ACL list back, so "rc=0
with a panic on stderr"
is measured rather than trusted.

Source of every changed shape

  • filter types and VmStateNamescontracts/outscale.json and osc-sdk-go's
    FiltersVm/FiltersVolume/FiltersSnapshot, cross-checked;
  • the 400 body — the pack's existing badRequest, unchanged;
  • Exoscale's 404 and bare message — unchanged; egoscale.APIError requires
    message only, and corpus/exoscale/exo-refusals.jsonl records exactly that;
  • X-Feint-Not-Emulated — feint's own namespace;
  • the WARN level — the rule A load balancer whose backends are on another subnet is accepted by the API and refused by OVN #457 already states at the balancer's sibling refusal.

An instrument lied, and two silent defects were cancelling out

The corpus gate went red on ReadKeypairs, and the recorded request is what is
wrong
: oapi-cli sent Filters.KeypairNames as an array, the proxy's redaction
flattened it to one string, and until now the pack read the undecodable filter as
absent and answered 200. Two silent defects cancelled out, and that gate had been
passing by accident. redactValue now keeps a list of scalars a list; the two
legacy lines are in corpus/accepted.json with their citation, and the staleness
rule deletes them when that corpus is re-recorded.

The comment that over-claimed, corrected rather than defended

Three mutations written for this lot did not bite: the matchers'
err != nil → return false branches are defence in depth, not the guard —
json.Unmarshal leaves the slice empty, so a present filter with no accepted
values already matches nothing. filters.go now says that, instead of citing a
test it cannot redden, and those three are not declared in the spec.

Gates

prepush 0 — read as its own exit code, not a pipeline's. Legs probe,
exo-cli, octl (147.9 s), scw-cli, fields (215.7 s), terraform — all
green. limits:check 0 on this branch and 2 on main, which is the next
section.

falsify -- refusals-are-legible.json: 7 of 7 bite, compiled=yes on each,
green after restoration.

The ledger line, and why it is here

limits:check is red on main right now, on the section "A machine's route out"
— not because of anything in this tree, but because that section's body cites
#514, which closed at 09:33Z today, the same minute as 2879888. The clock
made it stale, not a change.
The section was read before its acknowledgement was
re-dated, and the commit body says that rather than the false reason it first
gave.

Worth recording about the instrument itself: limits-acks.py queries GitHub live,
so its verdict depends on a clock and on network state, and two runs minutes
apart can legitimately disagree — one did, during this review. A verdict that
depends on time reads exactly like a verdict about a checkout.

What was not obtained

  • FEINT_VM=incus-ovn conformance:leg -- runtime, which testplan
    prescribed because the diff touches internal/core/machine/binding.go — where
    the only change is slog.Errorslog.Warn. No runtime behaviour branches on
    a log level. testplan erring on the expensive side again: it routes on
    paths, not on what a change does.
  • An Exoscale operation feint declines is refused with a bare 404 the official client reads as an ordinary empty answer: three refusals inside a fully green stack #477's other half. A header is invisible to Terraform and to a human
    reading an apply, so a run can still be green while three declined operations
    were called. Telling an operator needs a surface feint status does not have.
    Documented in docs/architecture.md and at Pack.NotFound as the open
    remainder rather than left implied.
  • No real-cloud measurement anywhere: no account, and corpus/ holds no
    reverse-DNS exchange, so "the real cloud answers 404 when an elastic IP has no
    reverse record"
    stays the issue's deduction, and limits.md now labels it as
    one.

… declared applied now exclude (#566)

`filters.go` opens on "a filter is either applied or refused, never ignored"
and the code beneath it ignored four. Reading a filter has three answers —
applied, absent, unreadable — and the reader kept two, folding the third onto
"absent", which is the one reading that answers 200 with the whole inventory.

Measured on 2026-08-28 against main@2879888, and wider than the issue stated:

    Filters.VolumeSizes  [40]     -> 200, both volumes   (the declared type)
    Filters.VolumeSizes  ["40"]   -> 200, the 40 GiB one (a type the API has not)
    Filters.VolumeIds    "vol-x"  -> 200, both volumes
    Filters.Progresses   [7]      -> 200, four snapshots at Progress 100
    Filters.AccountIds   ["…000"] -> 200, four snapshots at AccountId …001

The second line is the inversion: the only shape the pack could read was the
one the API does not declare. The last two are not decode failures at all —
`snapshotFilters` named three filters `snapshotMatches` never mentioned, and
AccountIds is an ordinary list of strings, so no decoder could have caught it.

Every shape here comes from contracts/outscale.json, extracted from Outscale's
own OpenAPI: VolumeSizes and Progresses are `items: {type: integer}`, the
identifier filters are strings, LinkRouteTableMain is a bare boolean.

What changed:

- a filter carries its kind (filterSpec), and refuseFilters refuses a value
  not written that way with a 400 naming the filter and the shape;
- the matchers fail closed on an unreadable value, so the direction that
  produces a silent success is the one the code cannot take;
- VolumeSizes, Progresses and AccountIds are compared;
- ReadVms serves VmStateNames, which is what FiltersVm declares. It served
  VmStates — FiltersVmsState's, one call over — so it refused the real filter
  and applied an invented one, and TestTheServedFiltersFilter drove the
  invented one. Found by the new kind control on the day it was written.

Four controls, because no one of them sees what the others do:

- TestEveryFilteringOperationDeclaresItsFilters walks the mounted routes whose
  request carries Filters upstream, so a read added later cannot escape;
- TestEveryDeclaredFilterKindIsTheOneTheContractDeclares holds every kind
  against the document;
- TestAnUnreadableFilterMatchesNothingRatherThanEverything pins the branch;
- TestEveryDeclaredFilterCanExcludeSomething is the witness no type could be:
  every declared filter is sent a value nothing carries, and the answer must
  be empty. It fails on AccountIds without this change.

And the instrument that lied on the way. The corpus gate went red on
ReadKeypairs, and the recorded REQUEST is what is wrong: oapi-cli sent
Filters.KeypairNames as an array, and the proxy's redaction flattened it to one
string because KeypairNames matches the "key" carrier. Nothing could see it —
the pack read the undecodable filter as absent and answered 200 — so two silent
defects cancelled out and the gate passed by accident. redactValue now keeps a
list of scalars a list, for the same reason it already keeps a null a null; the
two lines already on disk are accepted in corpus/accepted.json, and the
staleness rule deletes those entries the day that corpus is recorded again.

Driven by the real client: `mise run conformance:leg -- octl` (147.85 s, green)
now asserts that VolumeSizes and Progresses exclude, which no leg had ever
asserted for any filter.

Assisted-by: Claude Code (claude-opus-5)
…f 48 error sites were already right (#474)

Fourteen level=ERROR lines over fifteen stack replays were all one call: the
deliberate, documented refusal to boot an image identifier no catalogue holds.
The run that printed five of them applied 54 resources of 54, matched its
reference and destroyed 54 cleanly, so an operator grepping ERROR found
fourteen lines about a documented behaviour and nothing about the run that
failed. In one of those logs the sibling refusal 200 ms later was already a
WARN — loadbalancer_dataplane.go's ErrBalancerNotDistributed (#457), whose
comment states the rule: a limit is not an incident.

The rule, now written where the refusal is:

    an ERROR is something this emulator did not do that it was built to do;
    a WARN is something it deliberately declines and documents, where the
    API answer stays honest.

The issue's own "what I did not measure" is measured: of the 48 ERROR sites
under internal/ on 2026-08-28, this was the only one on the wrong side. Its
neighbours are failures and stay ERROR — a start the driver refused, an image
build that could not fetch its source, a pack that declares no interface plan
(plan.go says why that one is a fault in the pack rather than a decline).

Only the level moved. The boot is still refused, the resource still reads
FailedState, and the four keys still say what to do about it — all three
asserted.

TestADocumentedRefusalIsAWarningAndAFailureStaysAnError fails without this, and
fails in both directions: a change that made every refusal a warning passes its
first subtest and fails the other two. Reproduced before the fix — the image
subtest red on `level=ERROR`, the two failure subtests green — then green after.

docs/limits.md's section is rewritten from a shipped limit into the fix and the
measurement, because a limit that no longer holds is worse than an undocumented
one (#558).

Assisted-by: Claude Code (claude-opus-5)
… status that would have carried it fails a real client (#477)

An operation feint declines answered a bare 404 in the Exoscale pack, which is
also what the cloud answers for an elastic IP with no reverse record. Replaying
the register's best third-party stack — seven applied, empty second plan, seven
destroyed — the recorder showed three refusals of
GET /v2/reverse-dns/elastic-ip/{id} and nothing anywhere said so.

The issue offered three remedies. The second was measured and is refused.

Answering 501, as the Scaleway pack already does for its own space, is legible
and costs exo 1.95.1 / egoscale v3.1.36 nothing in latency: `exo dns list` at
22, 21 and 19 ms against the 19 ms of a served route, one attempt, no backoff,
reading "Not Implemented: feint does not serve …" instead of "Not Found: …".
And it FAILS `exo compute instance create`, which calls
GET /v2/reverse-dns/instance/{id} after every create and treats anything but a
404 as fatal: the exo-cli leg died at "instance create rejected" under 501 and
passes under 404 (measured 2026-08-28, both directions).

That is the symmetric defect the polar star forbids — a refusal loud enough to
fail a client the real cloud would have served — and it generalises: for an
operation whose real 404 means "this object has no such record", no status can
carry the distinction. Neither can the body here: egoscale.APIError requires
`message`, declares no code field, and the one refusal recorded from the real
cloud carries exactly that, so a field would be an invented format (rule 4).

So the marker goes out of band, and in the shared layer rather than in each
pack — a control copied into three packs is one the fourth forgets, and three
spellings of a refusal is what this issue is about:

    X-Feint-Not-Emulated: exoscale

set by emulator.handleUnrouted on every unrouted refusal, beside X-Feint-Fault
and X-Feint-Probe, which faults.go already documents as headers no real cloud
sends. Its value is the pack that owns the URL space, read from this process's
mount table — never the path the client sent, the rule the neighbouring warning
already follows. It is not set on a served answer, nor on a 404 that is an
ordinary missing object, nor on a path no pack claims: a marker on every answer
marks nothing, and all three are asserted.

Measured against all three packs, so the three dialects now share one marker
while keeping the statuses their own clients need — Scaleway 501, Outscale 404
(oapi-cli backs off 12 s on a 501), Exoscale 404.

What this does NOT do, and #477's remaining half: a header is invisible to
Terraform and to a human reading an apply, so a run can still be green while
three declined operations were called. Reporting that to an operator needs a
surface `feint status` does not have — and resolving a path to a declined
operation name needs contracts, which are off by default. docs/architecture.md
says so where a reader meets the table.

Gates: emulator.TestAnUnroutedAnswerCarriesTheNotEmulatedHeader,
TestADeclinedOperationKeepsTheStatusItsClientNeeds,
`mise run conformance:leg -- exo-cli` green, corpus and docs unchanged.

Assisted-by: Claude Code (claude-opus-5)
…and the one remedy that would have been a fix is disproved (#505)

The issue offered three ways out and only one would have been a real fix: that
this emulator answers something else, without lying, that does not trigger the
CLI's faulty assertion. It is false, and both halves of the disproof are here.

Read, in the module cache rather than guessed:

- ZonedAPI.DeleteACL ends on `s.client.Do(scwReq, nil, opts...)` — the response
  is decoded into nil, so NO body this emulator sends reaches the faulty path;
- lbACLDelete's Run returns `&core.SuccessResult{}` unconditionally on a nil
  error, so any truthful success produces the value that is dereferenced;
- the interceptor guards its pre-fetch with
  `argsI.(*lb.ZonedAPIDeleteCertificateRequest)` where the argument is a
  `*ZonedAPIDeleteACLRequest`, so getACL is nil on every path.

Measured, with the emulator's own fault injection, on 2026-08-28:

    204 (what it answers)          rc=0  panic: yes  the ACL: deleted
    500 (PUT /_feint/faults)       rc=1  panic: NO   the ACL: survives

and the three sibling verbs — acl get, acl update, acl create — carry the same
interceptor, exit 0 with empty stderr and never panic, because they answer an
*lb.ACL rather than a *core.SuccessResult. The fault sits exactly at "the runner
returned a success", and nowhere near this emulator.

So the only lever left is failing a delete that worked, which loses the resource
and lies about it. Verdict: documentation, not a fix — and not the third option
either. Filtering the line in the suite would hide a real upstream defect.

What ships instead is the honest half of tolerating it: scw-cli.sh reads the ACL
list back after the delete, so "rc=0 with a panic on stderr" is measured rather
than trusted, and the day a future scw turns that panic fatal the suite fails on
its own. The step also carries the mechanism and points at #505, because whoever
meets that orphan line in a log is reading the suite, not the issue tracker.

docs/limits.md's section keeps its measurement and gains the disproof, and its
one deduction — that a real account would panic the same way — is now labelled
as a deduction rather than left reading like a measurement.

`mise run conformance:leg -- scw-cli` green with the new assertion; the noise
line is still printed, which is the point.

Assisted-by: Claude Code (claude-opus-5)
… the three that could not

Each guard added for #566, #474 and #477 is mutated in a copy outside the tree,
by neutralising a condition rather than deleting a term, and every one of the
seven reddens the test that names it.

Three more were written and are deliberately absent, because running them
disproved their own comment. The matchers' `err != nil -> return false`
branches were cited as though TestAnUnreadableFilterMatchesNothingRatherThan
Everything held them; neutralised one at a time, that test stays green.
json.Unmarshal leaves the slice empty on a failure, and a filter that is present
with no accepted values already matches nothing — the property is structural,
and those branches are defence in depth against a future change to
filterSet.strings, not the guard.

So filters.go now says that about itself instead of citing a test it cannot
redden, which is this repository's own "un commentaire n'est pas un contrôle"
found inside the change that fixes an instance of it. A spec entry that cannot
fail is the voided verdict the harness exists to refuse, so it is not declared.

`mise run falsify -- tools/falsify/specs/refusals-are-legible.json`: 7 of 7 bite,
compiled=yes on every mutation, green after restoration.

Assisted-by: Claude Code (claude-opus-5)
…en and one because it was read (#558)

The #474 section: its heading changed when the limit was lifted, and the ledger
is keyed by heading, so its line was orphaned. Re-keyed and dated today, which
is when the section was rewritten.

The #507 section, "A machine's route out": re-dated because somebody read it,
which is the only thing this ledger records and the only thing that makes it
worth having.

What was read, on 2026-08-28: the section itself — the shape/outbound/DNS table
measured on 2026-08-26, #202's reasoning for why a routed NIC has no route out,
and the consequences list — then `git show 2879888` to see whether #514 could
have moved any of it. Its only touch on the routing layer is
`func (r Reconciler) router() Router` becoming `router() router`: an interface
renamed to unexported, no NAT, no resolver, no interface shape, no cloud-init.
The section's own named control, TestAPackageStepWithNoRouteOutIsSaidOutLoud,
ran green today in the file this branch edits for #474. The limit stands.

AND THE JUSTIFICATION THIS COMMIT FIRST CARRIED WAS WRONG, which is worth more
than the correction. It said the section was "RED BEFORE THIS BRANCH", framed as
a property of the tree that this lot happened to clear. It is not a property of
the tree at all:

  - #507 closed 2026-08-26 and its acknowledgement reads 2026-08-27, so the
    guard was satisfied on its own title issue;
  - the tool's subject is #514, which the section body cites once ("the
    architecture audit (#514), not a patch") and which closed 2026-08-28T09:33Z
    — the same minute as 2879888, the commit this branch starts from.

So the section went stale this morning because a clock ticked, not because of
anything in 2879888's content and not because of anything here. A verdict that
depends on wall-clock time and on GitHub's state reads exactly like a verdict
about a tree, and that is how it was misreported. `mise run limits:check` on
2879888's own two files, run in this repository at 13:00 today, still exits 2
and names #514.

Assisted-by: Claude Code (claude-opus-5)
@stephrobert
stephrobert merged commit 8b441d5 into main Aug 28, 2026
30 checks passed
@stephrobert
stephrobert deleted the fix/566-477-474-505-how-a-refusal-is-spelled branch August 28, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment