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
Conversation
… 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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.gohas stated the rule in its own preamble for a year: "a filter iseither applied or refused, never ignored." The code beneath it ignored four.
Measured against
main@2879888, and wider than the issue said: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
AccountIdsis a plain array-of-strings filter — perfectly decodable —that nothing compared:
snapshotFiltersnamed three filterssnapshotMatchesnever 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 thefilter and its declared shape,
ProgressesandAccountIdsdiscriminate.octldrives all of it, because it sends integers.
A fifth defect fell out of the new control:
ReadVmsservedVmStates,which belongs to
FiltersVmsState. It refused the realVmStateNamesandapplied an invented name — and
TestTheServedFiltersFilterdrove the inventedone, 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=ERRORlines 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_emulatedthisemulator already has — is refused, with the measurement: 501 costs
exonothing in latency (22/21/19 ms against a 19 ms baseline) but fails
exo compute instance create, which readsGET /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 inhandleUnroutedfor all three packs, besidethe existing
X-Feint-FaultandX-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
scw2.56.3 panics (recovered, rc=0) on every successfullb acl delete. Theonly 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.DeleteACLdecodes the body intonil, so nothing the emulator answersreaches the faulty path;
Runreturns success unconditionally on a nil error;and the interceptor asserts
*ZonedAPIDeleteCertificateRequeston a*ZonedAPIDeleteACLRequest, sogetACLis nil everywhere. The only lever isfailing a delete that worked — measured with
PUT /_feint/faults: no panic, andthe ACL survives.
Filtering the line was refused too: it would hide a real upstream defect. What
ships is the honest half —
scw-cli.shnow reads the ACL list back, so "rc=0with a panic on stderr" is measured rather than trusted.
Source of every changed shape
VmStateNames—contracts/outscale.jsonandosc-sdk-go'sFiltersVm/FiltersVolume/FiltersSnapshot, cross-checked;badRequest, unchanged;message— unchanged;egoscale.APIErrorrequiresmessageonly, andcorpus/exoscale/exo-refusals.jsonlrecords exactly that;X-Feint-Not-Emulated— feint's own namespace;An instrument lied, and two silent defects were cancelling out
The corpus gate went red on
ReadKeypairs, and the recorded request is what iswrong:
oapi-clisentFilters.KeypairNamesas an array, the proxy's redactionflattened 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.
redactValuenow keeps a list of scalars a list; the twolegacy lines are in
corpus/accepted.jsonwith their citation, and the stalenessrule 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 falsebranches are defence in depth, not the guard —json.Unmarshalleaves the slice empty, so a present filter with no acceptedvalues already matches nothing.
filters.gonow says that, instead of citing atest it cannot redden, and those three are not declared in the spec.
Gates
prepush0 — read as its own exit code, not a pipeline's. Legsprobe,exo-cli,octl(147.9 s),scw-cli,fields(215.7 s),terraform— allgreen.
limits:check0 on this branch and 2 onmain, which is the nextsection.
falsify -- refusals-are-legible.json: 7 of 7 bite,compiled=yeson each,green after restoration.
The ledger line, and why it is here
limits:checkis red onmainright 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 clockmade 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.pyqueries 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, whichtestplanprescribed because the diff touches
internal/core/machine/binding.go— wherethe only change is
slog.Error→slog.Warn. No runtime behaviour branches ona log level.
testplanerring on the expensive side again: it routes onpaths, not on what a change does.
reading an apply, so a run can still be green while three declined operations
were called. Telling an operator needs a surface
feint statusdoes not have.Documented in
docs/architecture.mdand atPack.NotFoundas the openremainder rather than left implied.
corpus/holds noreverse-DNS exchange, so "the real cloud answers 404 when an elastic IP has no
reverse record" stays the issue's deduction, and
limits.mdnow labels it asone.