Repository navigation
feat(s3): one local object-storage data plane behind three provider doors, and a client that lands on it (#850, #849) - #863
Merged
Conversation
Store immutable SHA-256 blobs behind logical bucket/key metadata. Verify bytes on reads and reopen; retain blobs on deletion. Exclude concurrent root owners and unsafe parents. No S3 routes, snapshot changes or garbage collection. Refs: #850 Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adapt the neutral ObjectStore to bounded path-style CRUD. Decode aws-chunked framing and verify CRC32/SHA-256 before metadata publication. Prove five lifecycle operations over trusted loopback HTTPS, plus official-client checksum rejection. Keep provider mounting, interception, ETags and checkpoints out of scope. Validation: mise run check and conformance:s3 passed; 15 declared mutations compiled and bit, with restored baseline green. prepush remains red on two untriaged upstream Scaleway LB operations, unrelated to this change. Refs: #850 Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an explicit cumulative blob-byte budget with startup inode inventory, atomic upload reservations and retained-byte accounting after publication or cleanup failures. Keep the neutral format and unlimited Open API. Probe excess input without writing beyond the reserved size; report quota refusals as Feint HTTP 507. Record the accepted same-port S3 door ADR and selected 1 GiB cumulative / 256 MiB simple-PUT deployment limits. Server mounting remains a subsequent property. Validation: repository check, docs check and bounded official AWS CLI HTTPS proof passed. Ten quota mutations, sixteen neutral-byte mutations and fifteen gateway mutations compiled and bit, with restored baselines green. Upstream Scaleway drift remains unrelated and untriaged. Refs: #850 Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Declare prefixed provider/region doors over one bounded neutral store. Carry explicit storage settings through serve/start/restart, discover only live S3 endpoints for env, and report separate HTTP response evidence without altering SDK coverage. Prove AWS CLI 2.32.17 byte/checksum identity and literal keys through the actual process across restart. Keep ancillary provider CLI operations, hardcoded Terraform endpoints and reset outside this slice. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Persist neutral bucket attributes and the explicit Suspended configuration, serve measured private ACL/tagging/list shapes, and export SDK-compatible bare endpoints. Prove scw create/list/get/delete alongside AWS byte checks through an actual restart. Twenty new mutations and all sixty-four inherited mutations compile and bite. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pin exo 1.95.1 and prove locally that both unsigned bucket discovery and subsequent upload omit the configured path prefix. The measurement listener refuses PUT deliberately; this does not certify an Exoscale lifecycle. Preserve the mounted AWS/scw witness and document the addressing blocker before changing the accepted door design. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add opt-in synthetic provider/region HTTP proxy authorities without DNS, TLS, forwarding or another listener. Preserve prefixed AWS/scw addressing and select the Exoscale host through the pack's client declaration.\n\nProve real exo uploads, AWS byte/checksum retrieval and actual restart; retain the negative prefix measurement, browser/peer guards, quotas and control-plane routing. Version the S3 report at 2 and CLI surface at 27. Complete Exoscale lifecycles remain deferred. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Serve bounded ListObjectsV2 and single-byte ranges through the existing scoped doors. Persist publication dates in neutral metadata v3 while retaining read-only v1/v2 compatibility and explicitly refusing undated legacy listings. Real Exo 1.95.1 downloads 73,728-byte and 6,340,608-byte objects before and after actual restart; AWS independently decodes pagination. All 25 new and 96 tightly coupled inherited falsifications compile, bite and restore green. Deterministic checks, tagged lint, generated docs, official-client witnesses and the partial probe leg pass. Independent drift remains code 2 for the two untriaged Scaleway LB operations. The limits freshness signal also reports ten unchanged sections with stale acknowledgements; their dates were not refreshed without an audit. Refs #850 Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Verify checksums and closed XML before deleting unversioned references in one metadata transaction. Real Exo removes four keys; AWS verifies empty inventory and Quiet/idempotent results across restart while Scaleway bytes remain intact. Multipart and complete bucket workflows remain open in #856 and #858. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resume durable upload parts across restart, stream verified bounded assembly, and abort real dangling sessions through Exo bucket removal. Prove AWS/scw/exo lifecycles and 35 multipart mutations; preserve v1-v3 reads and document v4 rollback, quota and protocol ceilings.\n\nRefs #856. Assisted-by: GitHub Copilot (Copilot SDK in VS Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… collection Keep full validated manifests including upload sessions in the exclusively owned store root. Verify blobs before reset, exclude active mutations through the lifecycle gate, and protect checkpoint references during explicit quota-aware collection. Assisted-by: GitHub Copilot (model not exposed by agent runtime) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…probe that proves the native inventory One slice of #850, and it does NOT complete it. #850, #853, #858 and #849 stay open; what each still lacks is at the end of this message. ## What the slice carries Exoscale, the ordinary bucket cycle (#858). `exo storage mb`, `storage show`, removal and the native inventory, with the ACL/CORS/ownership discovery the client performs before anything else. The refusals are the recorded ones rather than invented successes: absent CORS is a 404 NoSuchCORSConfiguration whose message carries its final period, absent ownership controls a 400 OwnershipControlsNotFoundError. Both come from the authorized empty-bucket recording kept at docs/evidence/exoscale-sos-empty-bucket.json, whose own header states what was redacted and that it is not an unmodified transcript. The native inventory, exoscale/v2.list-sos-buckets-usage, serves Store.Usage: logical published object bytes, never retained blobs or unpublished parts. It errors on disabled storage and on malformed or legacy scopes and dates rather than reporting a zero it cannot justify. Shared S3 discovery, and the transport under it. s3.LocalTransport forwards only declared HTTPS cloud authorities to one explicit loopback HTTP emulator, never an inherited proxy and never a cloud-capable fallback. It preserves dotted virtual-host buckets and literally escaped keys, refuses an undeclared authority BEFORE any dial, and refuses two declarations where one is a suffix of the other. Shapes come from the pinned official provider cassette. Scaleway Terraform, measured rather than assumed: the published provider 2.82.0 ignores SCW_S3_ENDPOINT in its S3 client, which both the pinned source and an isolated failure proved. The witness in internal/cli/object_storage_terraform_conformance_test.go gets apply, two empty plans, and 122,880 identical bytes across byte-store close/reopen, destroy and explicit garbage collection. ## What this session added to it The probe proves the native route instead of failing on it. It answered 503 "Object Storage is disabled" and the probe — which builds its request from the operation's own schema, and that schema declares a 200 body — counted that as a route answering the wrong shape. Two populations needed fixing and only the first was obvious: internal/probe/probe_test.go wires a byte inventory, 0700 on its parent because the store refuses a writable parent with no sticky bit, and it is right to mise.toml, task conformance starts the emulator with --objectstore under the run's own mktemp, so `feint probe` in the real pass sees what the in-process test sees Proved both ways: without the flag, 98 operations probed and 2 failures; with it, 100 probed and 0. Fixing only the Go test cost one evidence run, which is the population trap this repository keeps a skill about. Three easier answers were measured and rejected. Declaring the axis Unearnable is a lie, because the axis CAN be earned and TestAnUnearnableAxisIsNotAlreadyEarned would bite. Declaring the 503 in the contract is a worse lie, because Exoscale documents no such answer and the contract would describe an API nobody has. Teaching the probe to skip would sub-declare an operation that is served and checkable. `exo storage list` now drives the route in the Exoscale suite. TestEveryUndrivenOperationSaysWhy demanded either a client or a reason, and the reason would have been false: exo calls ListSOSBucketsUsage from cmd/storage/storage_list.go. A local harness said otherwise and was wrong — it failed on the CLI's default zone ch-dk-2 before calling anything, which is the failure exo-cli.sh documents in as many words. The assertion is an EMPTY inventory over zero buckets, because that is the honest derivation at its smallest and #858 asks for values derived from real buckets. Two falsification mutations, which the handoff recorded as interrupted. The three existing ones all drive LocalTransport's accepting path; neither touched the two refusals in NewLocalTransport, where the blast radius is: a non-loopback target forwards cloud-shaped requests off the machine, and overlapping authorities let map iteration order pick a provider's door. Both keep every name and suffix the condition with `&& false`, because the harness refuses a mutation that drops an identifier. 13 -> 15, all compiled, all bit. ## Verification, all run on this tree mise run check pass mise run docs:check pass mise run shapes:check pass mise run falsify:lint pass, 1528 mutations / 225 specs mise run lint-shell pass mise run release:surface pass, 1 named 0 excused 0 neither go vet -tags=conformance (cli, s3, exoscale, objectstore) pass mise run falsify -- s3-storage-discovery.json 15/15 compiled and bit mise run falsify -- s3-gateway.json bit mise run falsify -- s3-bucket-configuration.json bit tools/conformance/exoscale/exo-cli.sh pass, new assertion included mise run conformance:s3:mounted PASS 21.64s mise run evidence:update regenerated, both legs, 0 failure The isolated Terraform witness, rebuilt from this tree and replayed under a disposable loopback-only network namespace (operator-authorized sudo for the namespace only, privileges dropped by setpriv immediately after): Scaleway 2.82.0: apply, two empty plans, 122880 identical bytes across reopen, destroy and owned GC; cloud-host TLS interception --- PASS (1.07s) conformance:s3:mounted witnessed real bytes rather than exit codes: 6,340,608 bytes verified by sha256 across an ACTUAL process restart (pid 2537132 -> 2537864), a multipart session above 5 MiB resumed after that restart and completed with identical bytes, and `exo rb` aborting a genuinely dangling upload. The evidence row the whole slice turned on: exoscale/v2.list-sos-buckets-usage driven:true probed:response contract:clean dataplane:true shape:unobserved behaviour:false negative:false Four axes earned, three not, and the three say why: no cloud recording arbitrates its shape, and no suite asserts its behaviour or its refusals yet. That row could not have been hand-written honestly, which is why the task was run three times rather than the file edited once. s3-bucket-configuration looked dead and was not: its mutation 20 targets internal/cli, where the evidence test was red. Its own test passes alone. ## What is NOT proved, and what each issue still needs The Terraform witness is a TEST HARNESS, not a shipped `feint run`. It uses a tiny arbitrary payload named qcow2 and proves no image or runtime path. #850 asks that a surveyed stack stop escaping to real Object Storage; what exists proves the transport. #850 also still needs: an OUTSCALE or OOS client path, a dotted-bucket TLS proof with an actual client, and a generated bounded capability statement in docs/confidence.md — whose table is hand-written on purpose, as docs_confidence.go says, because a verdict is a judgement. #853 is broader than the committed byte-checkpoint library: no coordinated CLI/control-plane/runtime checkpoint exists. #849's interception exceeds this harness. #858's closure criteria are not all met. Debts left visible rather than refreshed away: `mise run drift:check` is red here on lb/v1/ZonedAPI.AttachIpamIP and TransferToIpam, which is #859 — triaged and resolved upstream in main by #860, and an integration of main will carry it. An earlier local run of drift:check in this worktree reported "no drift" only because .upstream/ was empty, which is a false green and not a clearance. #857, the limits freshness review, is untouched. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…g else #850 asks for an OUTSCALE or OOS client path "if the current official tooling exposes it", and the honest answer was assumed to be no. It is yes, and every part of that was measured rather than read: octl v0.0.32 carries `octl storage`, "OUTSCALE Object Storage (OOS) management", with bucket, object, multipartupload and part subcommands `octl storage api` lists S3's OWN operation names — GetObject, PutObject, ListObjectsV2, CreateMultipartUpload — so OOS is S3, not a named API osc-sdk-go pkg/oos/client.go is built on aws-sdk-go-v2/service/s3 with `type Error = smithy.APIError`, so there is no proprietary error dialect pkg/profile/profile.go:114 reads OSC_ENDPOINT_OOS and endpoint.go:12 accepts an `oos` profile key, so the endpoint redirects with NO TLS interception, no fork and no dev_overrides pkg/profile/endpoint.go:27 gives the authority: %s://oos.%s.outscale.com That last one matters for #850's other half: a provider that cannot be safely intercepted gets its door declined rather than simulated. This one can, the same way octl.sh already redirects this client's IaaS calls. ## The envelope, which is the part nothing but a recording settles OutscaleMissingBucket is a third shape, not a reuse of either neighbour: <Resource> PRESENT and EMPTY Scaleway fills it with /<bucket>, Exoscale omits the element entirely <BucketName> ABSENT both others carry it x-amz-id-2 == x-amz-request-id the others derive them apart no X-Amzn-Request-Id unlike Exoscale final period on the message like Exoscale, unlike Scaleway Recorded by an ANONYMOUS GET on a name that does not exist. No credentials, so no signature, so the request is attributed to no account: nothing created, nothing modified, nothing billed. The user offered their Outscale account for this and it was deliberately not used, because the two existing dialects were recorded the same way and the cheaper measurement is the one that settles it. Kept at docs/evidence/outscale-oos-missing-bucket.json with its provenance and what each difference means. `server: nginx/1.20.0` on that answer is itself worth knowing: OOS is Outscale's own implementation, so the envelope is theirs to differ in. Resource and BucketName became *string, because three states had to be told apart and `omitempty` on a string expresses two: nil omits the element, a pointer to "" renders it empty, a pointer to a value fills it. The NoSuchTagSet answer in bucket_configuration.go is addressed rather than changed. ## What is NOT claimed The door, its endpoint variables and its missing-bucket envelope. NOT that octl's storage surface round-trips: no suite drives `octl storage` yet, so every operation of this door is unproven by a real client whatever the S3 server under it can serve. The conformance axis says nothing about it, which is correct. It is therefore NOT in the default --s3-doors, which stays scaleway/fr-par,exoscale/ch-gva-2. A door enabled by default is a door the project vouches for. BucketConfiguration, ObjectDiscovery, ListObjectsV1 and PrivateACLWrite stay at their zero value rather than copied from the Scaleway profile next door. Each switches on an answer shape, and this door has one recording to its name. #850 is not closed and neither is anything else. ## Verification mise run check / docs:check / shapes:check pass mise run falsify:lint / lint-shell / release:surface pass mise run falsify -- oos-missing-bucket-envelope.json 4/4 compiled and bit The door answers, measured on a running emulator with --s3-doors ...,outscale/eu-west-2: HTTP/1.1 404 <?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchBucket</Code> <Message>The specified bucket does not exist.</Message> <Resource></Resource><RequestId>...</RequestId></Error> declaration prefix present, Resource present-and-empty, no BucketName, final period — all four checked separately Two tests the harness forced into existence rather than letting me assume them. TestUploadHeaderValidationAndConstructor gains a shape past the last measured one, because mutation 4 widened the constructor's allow-list into a range check and nothing went red: the case did not exist. And the first spec aimed that mutation at a test name that does not exist, where `go test -run` matches nothing and exits 0 — a mutation pointed at a missing test is green forever, which is the quietest way a falsification can lie. TestS3DeclarationsRejectUnknownMalformedAndDuplicateDoors listed "outscale/eu-west-2" among the declarations to refuse, as the provider without a door. It has one now, so that entry went and three Outscale-specific malformed cases replaced it — plus an assertion that the door is ACCEPTED and declares the measured authority, because a guard that only refuses passes every refusal test and breaks the product. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s hiding behind a configuration switch The first Outscale Object Storage client path this project has, which is the criterion #850 states: "one OUTSCALE or OOS client path is driven if the current official tooling exposes it". ## The coupling that kept the door from serving its client ListBuckets lived behind Config.BucketConfiguration. `git log -S` names the commit that put it there: 7c3e087, "prove private Scaleway bucket lifecycle", which measured that provider's ACL, tagging and list shapes in one go. A coupling of opportunity, read afterwards as one of meaning — the field's name says "the configuration of a bucket" and it was taken as "this door may enumerate buckets". It cost a real client. octl v0.0.32 opens with ListBuckets and reaches the door at exactly the canonical bare endpoint, captured on the wire with a listener that printed the request line and nothing else: OSC_ENDPOINT_OOS=...../outscale/eu-west-2 -> GET /_feint/s3/outscale/eu-west-2?x-id=ListBuckets OSC_ENDPOINT_OOS=...../outscale/eu-west-2/ -> GET /_feint/s3/outscale/eu-west-2/?x-id=ListBuckets so octl preserves the endpoint's path, unlike exo which drops it, and one character separated a served request from a 400. With the slash, the door was reached and refused anyway: "invalid bucket or object key", because the Outscale profile leaves BucketConfiguration off on purpose — that switch also turns on discovery answers whose OOS shapes nobody has recorded. A door was refusing the one operation its client opens with, to avoid claiming five others. The same switch held the creation date, so once the listing was served CreateBucket answered 200 and the ListBuckets after it answered 501, "legacy bucket listing has no measured creation metadata". Two gestures of one client disagreeing about one bucket. Both are decoupled. A creation date is a property of a bucket, not a configuration of it, and ListBuckets renders it. What stays behind BucketConfiguration is the CreateBucketConfiguration BODY, which is what the field names. The condition is removed rather than replaced by a second flag: a flag all three doors set to true is a constant wearing a switch's name, and this repository prefers no guard to one that cannot fail. Nothing widens for Scaleway or Exoscale, which both already carried it. ## A defect the new test found on its own `?x-id=ListBuckets&x-id=ListBuckets` was served as a bucket listing, because len(query) counts KEYS and a duplicated parameter is one key with two values. Now len(query["x-id"]) == 1, which is the duplicate-parameter refusal TestBucketConfigurationRefusals/duplicate-query already holds one step over: a client that sends a parameter twice has not established its intent, and S3 does not define which value wins. ## Measured against the account, read-only TestMountedOOSOfficialOctl drives three gestures, and two of its expectations were compared against the REAL OOS with the same octl binary and the same subcommands. One GET each, nothing created, no bucket on the account before or after; credentials never passed through the harness, since octl reads ~/.osc/config.json itself and that file stays behind its deny rule. ListBuckets, empty cloud [] (2 bytes) door [] (2 bytes) identical absent bucket, refused cloud `The server returned an error { "Message": null, "ErrorCodeOverride": null }` door byte for byte the same That second reading is why the test does NOT assert "NoSuchBucket" in octl's output: the client discards the S3 error code, on the cloud exactly as here. Two earlier versions of that assertion failed for that reason — first through HeadBucket, which carries no body at all, then through ListObjectsV2, whose body the client drops. Both failures were mine, and the cloud reading is what told them apart from an emulator defect. The envelope itself is asserted byte for byte in internal/s3 against docs/evidence/outscale-oos-missing-bucket.json. ## Three places the harness refused to let me assume The first falsification spec aimed its three mutations at TestMountedBucketListingAcceptsOnlyItsCanonicalBareEndpoint, which sounded right and covers none of this: all three stayed GREEN. A spec aimed at a test that does not cover its subject proves nothing while looking exactly like proof. That is what TestADoorWithoutConfigurationStillListsAndDatesItsBuckets exists for, and the mutations bite it. TestEveryCitedTestExists then caught the comment citing that test, because the name had been hyphenated across two lines and the citation read as a test that does not exist. And the creation-date mutation was refused for dropping identifiers; it keeps every name and empties only the value. ## Verification mise run check / docs:check / shapes:check pass mise run falsify:lint / lint-shell / release:surface pass mise run falsify -- oos-door-serves-its-listing.json 3/3 compiled and bit mise run conformance:s3:oos PASS ## Still not claimed One client, three gestures. Nothing about ACL, CORS, ownership or tagging discovery on OOS, whose shapes nobody has recorded; those switches stay off. No multipart and no byte round-trip through this door. The door is still not in the default --s3-doors, because a door enabled by default is a door the project vouches for, and one suite of three gestures is not that yet. #850 is not closed. Neither is #853, #858 or #849. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… digest #850's "PUT bytes [...] GET bytes" for a third provider. 4096 random bytes, put by octl, downloaded by octl, sha256 identical. Random and not text, deliberately: a byte path that passes through an encoding shows nothing on printable input. ## Three wrong ways first, and they are in the comment because the next person ## will try them in the same order `api GetObject` is not the way, with or without `-o raw`: it answers the operation's JSON with `"Body": {}` — 1032 bytes of envelope and none of the object. A test comparing that output would compare metadata and call it bytes, and it would pass. `storage object download` WITHOUT an output file HANGS. Measured: the call did not return and had to be killed, which is worse than an error — a suite built on it would stall rather than fail, and a stalled suite reads as a slow one. With `-O` it writes the file and returns 0. The key is POSITIONAL on that subcommand, not `--Key` as on `api GetObject`. I had abandoned this path one tool-call short of it, after the hang, and said so. `-O` was the form I had not tried. Persisting was right and stopping would have left a #850 criterion unproven for no reason other than my own fatigue. ## Verification mise run conformance:s3:oos PASS, four gestures [] over zero buckets, the shape the account answers created and found, with a creation date refused as a failure, and the inventory is untouched 4096 bytes round-tripped, sha256 identical check / drift:check / docs:check / shapes:check pass falsify:lint / lint-shell / release:surface pass drift:check is green here for the first time: main is merged in, which carries the #860 triage of lb/v1/ZonedAPI.AttachIpamIP and TransferToIpam. #859 is the debt that closes with it, and it closed by being triaged upstream rather than refreshed away here. ## Still not claimed Multipart through this door, and any discovery shape — ACL, CORS, ownership, tagging — whose OOS answers nobody has recorded. Those switches stay off in the Outscale profile. The door is still not in the default --s3-doors. #850 is not closed: the Terraform witness remains a test harness rather than a shipped `feint run`, the dotted-bucket TLS proof with an actual client is missing, and #849's interception exceeds what exists. #853 and #858 are open. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…re missing Measured material, no code change. The Outscale profile still declares no discovery shapes, and BucketConfiguration stays off on that door, because two of the four answers are not recorded and this corpus says so rather than letting the gap look filled. ## What is recorded GetBucketAcl, 200: an AccessControlPolicy with one FULL_CONTROL grant to the owner, so a fresh OOS bucket is private. Its XML declaration carries standalone="yes", which neither neighbour does on this answer. GetBucketCors, 404: NoSuchCORSConfiguration with NO final period. Exoscale's recorded answer to the same question HAS one, so copying that dialect would have been wrong by exactly one character. <Resource> is present and empty and there is a <RequestId> and no <BucketName> — the same envelope as the NoSuchBucket recording beside this file, which means that IS OOS's error envelope generally rather than a quirk of one code. ## Why this needed the account, where NoSuchBucket did not A bucket that does not exist answers NoSuchBucket to everything, ?cors= included, so the "this configuration is absent" shape only comes from a bucket that exists and has none. And an unsigned GET on a private bucket answers AccessDenied rather than the absence. So: a signed request, therefore the client, therefore `octl -v` to see the body at all. `octl -v` was proved against the emulator's already-recorded Exoscale CORS shape FIRST, and printed the full response body. An instrument that shows nothing locally shows nothing on a paid account either, and that control cost no cloud call. ## The account One bucket, created and deleted inside one script whose cleanup runs from a trap. No object written into it. 0 buckets before, 0 after, verified twice — once by the trap and once after a pause, because OOS throttled on the second run and the script read the empty answer as a possible leak. It alerted rather than going quiet, which is the right failure. The owner's canonical ID and DisplayName are NOT in this file: DisplayName is the account number. Both are replaced by the local fixture the emulator already uses, the same redaction the Exoscale corpus states for the same reason. ## What is deliberately absent GetBucketOwnershipControls and GetBucketTagging. The first run's output filter was too narrow to capture them; the second run was refused by throttling. They are not guessed, no switch is turned on for them, and the `incomplete` field of this corpus names them so the next session does not have to rediscover what is missing. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…akes no hostname (#849) A client that discards the endpoint it was given leaves for the real cloud. That is #280, and it is the reason #850 cannot be closed by serving more routes. Telling an operator to export four variables and mint a CA by hand is not a fix; it is the same wall with a paragraph in front of it. `feint run -- <command>` runs one child with the interception scoped to it: the proxy listens on loopback, the authority lives in a directory the run creates and removes, and the four variables are set on the child alone, never on the operator's shell, /etc/hosts or a system trust store. No privilege, and no network namespace: the Terraform witness already measured that the provider honours HTTP_PROXY, HTTPS_PROXY and SSL_CERT_FILE, which is why the namespace in that test is an escape CHECK rather than the mechanism. The design decision worth reviewing is that the command takes no hostname. An earlier draft of this commit took `--forward host=target`, and it was wrong for one reason: the operator typing the hostname is the operator able to mistype it, and a mistyped cloud host is not intercepted, it is *resolved*. The run then leaves for a third party carrying whatever credentials it holds, and the failure is silent, because a request that reached the real cloud looks exactly like a request that worked. So nothing is typed. The mounted doors come from the emulator's own report (GET /_feint/s3, read and validated by fetchS3) and each door's cloud authority comes from the pack in this binary. That split is the one s3.Profile already requires — an instance fact may come from a report, a wire dialect may not — and its consequence is that the set of possible targets is the set of doors that exist. There is no spelling of this command that sends traffic off loopback. Credentials are fail-closed. The emulator accepts any signature without checking it, so real keys prove nothing here and risk everything: a credential-shaped variable is refused unless its value is one of this repository's public fakes, and the refusal names the VARIABLE, never the value, because this output lands in terminals, CI logs and bug reports. Measured, not asserted. `feint run` against a door-serving emulator: curl asking for https://s3.fr-par.scw.cloud/ receives HTTP 200 and the door's own ListAllMyBucketsResult; an undeclared host exits 000, refused rather than tunnelled; the child's exit code 7 travels; no feint-run-* directory is left. A doorless emulator is refused by naming the two flags to add. One assumption this commit had to drop, and the check is what caught it: an earlier draft required 0600 on the CA file and the check reported 0644. proxy.WriteCA writes 0644 deliberately (intercept.go:294, "a public CA, no secret; must be readable by the client process"), so the requirement was mine rather than the system's. The privacy comes from the 0700 directory, and the only real secret, the authority's private key, is never written to disk. A Chmod would have recorded the wrong assumption as a value nobody reads back; the permission is therefore verified, and the test reads it from inside the child, where it matters. /falsify: five mutations, all compiling, all biting - the credential refusal neutralised, the value printed beside the name, a door with no measured authority accepted, a doorless emulator accepted, and the signal no longer relayed. The last one carries a correction of its own test's comment, which first claimed the leftover file held the signal relay. It does not: the deferred removal runs either way, and what holds the relay is the exit code. mise run prepush: green. CLI surface 28, with the help block, both README command tables, and the changelog in both languages. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…o privilege (#849) This closes #849's own "done means", which curl could not: a real Terraform client with a compiled-in Object Storage endpoint, driven through the command on the HOST network, with no sudo, no namespace and no change to the machine. TestScalewayTerraformStorageThroughFeintRunOnTheHostNetwork is the witness, and it is the mirror of TestScalewayTerraformStorageInIsolatedNetwork rather than a copy of it. That one REFUSES to run outside a fresh namespace and needs `sudo unshare`; this one refuses to run INSIDE one, because a pass obtained in a namespace would prove the namespace's containment and not the command's. The two are only worth having together. Measured on the published provider 2.82.0: apply complete, 139,264 identical bytes read back from the local byte store by sha256, an empty second plan, a complete teardown, inventory back to empty. The digest is the proof rather than the exit code — a request that went anywhere else cannot produce it, and the real cloud would refuse these deliberately public fake credentials long before storing anything. Three defects the measurement found in the command itself. 1. The virtual-host form was not intercepted. A bucket is addressed as <bucket>.<authority>, which is what Terraform uses, so the door was reachable by my own curl proof — path-style — and by nothing a real client does. The allow-list now carries a single-level wildcard per door. It widens only what is INTERCEPTED: LocalTransport still refuses a leading label that is not a valid bucket name, so interception stays wider than service and a nonsense bucket gets a local refusal rather than a trip to the cloud. 2. AWS_CA_BUNDLE broke the client it was meant to help. I had set it on the reasoning that an S3 client would want it; the apply then died on "unable to add custom RootCAs HTTPClient, has no WithTransportOptions, *http.Client", because the provider hands its SDK a prebuilt client. Removing it made the same apply complete. It is now stripped from an inherited environment too, which is the one thing this command edits of the operator's own config, and the reason is that no value of it can help: the station's bundle cannot contain an authority minted seconds ago. SSL_CERT_FILE, which crypto/x509 reads, covers every Go client. 3. My own test raced its subject. awaitReceipt polls stdout while runChild's goroutine appends to it; -race caught it, and the buffer is now guarded. `feint doctor` gains the leftover detection #849 asks for, and it decides rather than warns. Each run records its owning PID, so a live owner reads as a run in flight and a dead one as a leftover. Without that, the check could only report "a directory exists", which is true of every healthy run — the kind of diagnostic doctor.go's own header says people stop reading. A leftover is a warn, not a fail: it holds a public certificate in an owner-only directory, so it leaks nothing and blocks nothing, unlike checkLeftoverDHCP whose leftover kills the next run. Two harness lessons worth keeping, both from believing a green. The witness passed in 0.94s, which is implausible for init+apply+plan+destroy, so I timed each step instead of accepting it. The timings turned out fine (46ms and 263ms: the provider is linked, not unpacked, and the emulator is local), but reading them surfaced a real defect — the witness was green once and red the second time, because TF_PLUGIN_CACHE_DIR pointed at the same directory as -plugin-dir, so the first run unpacked into the mirror and every later run hit "cannot install existing provider directory to itself". A witness that only works on a machine that has never run it proves nothing. The timings stay in the test for the next reader. And my first two explanations for 2 leftover directories were both WRONG, which measuring said and guessing would not have. Neither SIGPIPE nor AWS_CA_BUNDLE caused them: my script had no before-count. With one, the Terraform cycle's three runs leave an delta of exactly 0, and the two directories turned out to be residue from the falsify mutation that forgets cleanup — by construction. /falsify: nine mutations, all compiling, all biting. Mutation 6 is the one #849 names in as many words, a run that forgets its cleanup, and its first draft stayed GREEN: `if err := os.RemoveAll(dir); err != nil && false` still CALLS RemoveAll. It removes a path inside the directory instead. Mutation 8 holds the virtual-host interception, and it is why that test had to cross the proxy: an earlier draft asserted the receipt text and the transport's routing, and neither touches the allow-list. mise run prepush: green, including -race. docs:check green. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rong one (#849) Three places said Scaleway Terraform Object Storage endpoints cannot be reached, and all three were false as of the previous commit. None was held by an instrument, which is #592's defect exactly: a sentence nobody owns cannot be contradicted, and here there were three of them. - docs/confidence.md:48 said "no". It now says opt-in, names the witness, and names what the witness does NOT cover. - internal/cli/env.go called the path uncertified. It now points at the command instead: variables cannot redirect a client that builds its endpoint in code, but that is a limit of `env`, not of the emulator. - docs/limits.md listed "hardcoded Terraform redirection" among the unsupported, and explained at length why it would stay there. That explanation is the interesting part, and it is kept rather than deleted. It held that redirecting the provider needs DNS interception plus a TLS certificate, priced the certificate half as cheap and the DNS half as neither cheap nor safe, and concluded on that split. The split was right. The REQUIREMENT was not: an HTTP forward proxy needs no DNS, because the client puts the hostname in its own CONNECT line, so the name arrives as data instead of as a lookup to be subverted. The expensive half of the blocker did not have to be paid because it did not exist. A refusal resting on a measured cost is still only as good as the question it measured. What is deliberately NOT claimed. capability.go carries no row for this, and that is not an omission: its matrix admits only control-plane rows proven by the conformance workflow, while this is a data-plane capability established by a witness that needs a provider mirror and therefore does not run on a pull request. Inventing a proof kind for one row would add a resolver nobody exercises, which is why the previous one was deleted with its mechanism in #644. The honest shape is what the table now says: a measurement you reproduce, not a gate. Putting the witness in CI is what would earn the row, and it is the next step rather than this commit's. OpenTofu is untested on this path and the table says so. mise run prepush: green, including the claim gate, which had nothing to refuse because the scaleway/terraform pair is already supported. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e claim is tied to it (#849, #850) The previous commit left a capability asserted in prose and established by a measurement somebody had to take. That is the shape #592 was filed for, and #850's criterion 59 names it: the Object Storage warning must become "a generated/bounded capability statement, not hand-edited marketing prose". A job of its own, not a leg of the matrix, because it needs something no other leg does: a local mirror of the pinned provider. It installs Terraform at the workflow-level pin with the same upstream checksums as the two other jobs that install it, mirrors the provider version read OUT OF the witness file rather than from a second variable, and runs the witness. It then asks `feint doctor` whether the job leaked an interception authority of its own — the diagnostic shipped for that purpose, used on itself. A MIRROR, and deliberately not a plugin cache at the same path. The witness was green once and red the second time on a developer's machine for exactly that reason, and a fresh runner would never have shown it, which is why the comment is in the workflow. What this does NOT do, and the note in the spec is the record of it: the row is still not GENERATED, because capabilityMatrix cannot express it. That matrix admits one axis — a client against a pack — and this is a data-plane capability for a pair already marked supported on the control plane. Widening it for one row would need a new mode and a new proof kind, and capability.go records why that is wrong: #644 deleted a proof kind precisely because no row rested on it, leaving a resolver nobody exercised. So the row is BOUNDED, which is the half of the criterion that prevents the defect. TestTheHardcodedEndpointRowIsOwnedByAnInstrument ties the claim to the instrument in three directions: the row may claim the path only while conformance.yml runs the witness, the witness must be DEFINED — a `go test -run` on a name that matches nothing exits 0, and that false green happened twice during this feature's own development — and the row must name what it leaves out, since "opt-in, Terraform" reads as "both engines" to anybody choosing between Terraform and OpenTofu. /falsify: one mutation, and the spec's note says why only one. Renaming the witness in the workflow makes the control bite. The "names what it leaves out" half is asserted but NOT falsified: the harness requires every name in `find` to survive into `replace`, and in a Markdown table there is no condition to neutralise, only words. That refusal is correct, so the note records which half of this control carries proof and which carries only a test. actionlint and zizmor clean. mise run prepush green. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…usal is not a fault Four CI jobs were red on this branch — probe, image, fields and exo-cli — and nothing local could see it. The cause is one line and it is PR #605 repeated to the letter: `mise.toml` was taught `--objectstore` and the other two conformance starters were not. The trap is the one CLAUDE.md states in as many words. `mise run conformance` drives every suite against ONE emulator, which was the one holding the flag, so the local pass was green BY CONSTRUCTION for any assertion about what that emulator serves. `conformance.yml` splits the clients into a matrix with an emulator per leg. The comment on the workflow's own start step already told this story about `--projects`, five red jobs and all, and the flag below it was missing anyway. So the fix is not three edits, it is the property: TestEveryConformanceEmulatorArmsTheSameObjectStore asserts that every starter arms the byte store, built beside the test that already does it for `--projects`. Its own detector was widened before it was trusted — Go's flag package takes `--objectstore=x` as well as `--objectstore x`, and a reader that knew one spelling would report a starter as unarmed the day somebody wrote the other. That came from asking what would make the detector wrong, which is cheaper than watching it fail. The `image` job stays deliberately unarmed, for the reason projectStarters already records: it runs the shipped container with no override, because "a proof that quietly passes different flags proves a different image". That leg needed the other half. `exoscale/v2.list-sos-buckets-usage` answers 503 without a byte store while its schema declares a 200 body, so the probe — which builds its request from the operation's own schema — reported a route answering the wrong shape. The 503 now carries the out-of-band marker #477 introduced for exactly this distinction: a status cannot say "this is the emulator refusing rather than a cloud answering". A MARKED 5xx is a skip. The narrowness is the whole of the safety, and both halves are falsified. Drop the marker requirement and the probe stops reporting server errors at all, including the deliberate faults of faults.go, which carry no marker. Widen 5xx to 4xx and it stops reading error dialects, since every pack marks its own unrouted answers. Either buys a green by giving up what the instrument measures. And no coverage was bought. Without the flag the exoscale probe goes from 2 failed to 0, with 26 skips becoming 28; WITH it the route is driven — 100 probed against 98. That second number is why this is a skip conditioned on a declaration rather than a route taken out of the plan, and it is the rule this file already follows elsewhere: branch on a declaration, never on a name you expect. Two notes on how this was found, since neither was deduction. The red was only visible because `conformance.yml` runs on `push` for main alone, so the branch had never been measured; a workflow_dispatch on the branch is what produced it. And the `hardcoded endpoint` job added in the previous commit passed, so the four failures are the Object Storage work that preceded it, not `feint run`. /falsify: two mutations, both compiling, both biting. mise run prepush green, including TestEveryCitedTestExists, which refused the first version of these citations for naming a test in another package without naming the package — "or a homonym anywhere satisfies this citation". actionlint, zizmor and shellcheck clean. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…object beside it (#850) #850 asks that one Exoscale SOS-dependent stack reach LOCAL object storage instead of the real endpoint. It now does: examples/stacks/exoscale creates a bucket against the local SOS door, and tools/conformance/stacks.sh asserts it is in that door's inventory after the apply — a bucket listed there cannot have been created anywhere else. Nothing is intercepted for it. SOS honours `sos_endpoint`, so the redirection is configuration; `feint run` is for clients that build their endpoint in code, and this is not one of them. ## What the measurements decided, rather than taste **It goes through the AWS provider.** The Exoscale provider has no bucket resource at all: on 0.72.0 its only storage resource is `exoscale_sos_bucket_policy`, which does not create a bucket, and whose three verbs this emulator answers 501 — no policy shape is recorded and this project does not invent formats. The AWS provider pointed at the door is also what Exoscale users actually write. **There is no `aws_s3_object`.** It applies and its bytes land in the local store, sha256 identical. But every later plan wants to update it in place, because it compares the ETag a read returns against the one it computed with `filemd5`, and this emulator returns none — verified on PUT, HEAD and GET alike. internal/s3/multipart.go states that position: an ETag is an opaque entity tag, not an MD5. Serving one on simple PUT means persisting it, which is a v5 byte format older binaries could not read. The choice was between that migration and an example carrying a permanent diff, and a permanent diff in an example teaches a reader that drift is normal here. **The byte store is armed from the declaration, not for everyone.** feint.yaml gains `storage.directory` and `storage.doors`, and `up` renders them into the flags the CLI already has — one direction only, as startArgs' own comment requires. Absent by default for `runtime.mode`'s reason: writing bytes is a side effect on the operator's disk, so it is asked for. **The store is NOT inside the repository**, and that is the object store's own guard rather than a preference: it refuses a parent writable by the group without the sticky bit. A checkout is commonly 775, so a path in the tree would work for one operator's umask and fail for another's, which is the worst possible property for an example. ## One decision I made and the tests reversed I first added `exoscale/ch-dk-2` to defaultS3Doors, reasoning that a single default beats a parity to maintain. Three tests of the env surface failed at once: two Exoscale regions make `feint env exoscale --s3` ambiguous, so it answers "select one with --s3-region" to somebody who asked for nothing unusual. A convenience for this repository's own stack, paid for by every reader. The default is back to two doors and the three conformance starters arm the wider list, with the door list added to TestEveryConformanceEmulatorArmsTheSameObjectStore so the three cannot drift. ## What the repository's own gates caught - `docs:check` refused a committed `.terraform.lock.hcl` under a stack: the generator reads constraints it reports as unresolved. No stack carries one; this one does not either. - The testplan's claim gate caught a sentence saying feint.yaml carries no declarative storage configuration, citing the ABSENCE of `--objectstore` from up.go as its evidence. Both flags are there now, so the sentence went rather than the gate. - `docs/environment.md` regenerated, since the schema grew two fields. /falsify: three mutations on the arming guard, all biting, including the silent direction — storage armed for every declaration would write bytes for a reader who never mentioned one. Two guards are asserted and NOT falsified, and the spec note says which and why: the harness requires every name in `find` to survive into `replace`, which neither a YAML endpoint nor a shell door list can satisfy. The inventory assertion was proved to bite by hand: with its variable emptied, the leg fails naming the door it looked in. mise run prepush green. actionlint, zizmor and shellcheck clean. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e leg survives it (#850) The previous commit turned the `image` job red, and the cause is the population trap CLAUDE.md states in as many words: a control eprouve on the richest run and broken by the poorest. stacks.sh exported TF_VAR_sos_endpoint unconditionally, so the stack asked for a bucket on every emulator. The `image` job runs the SHIPPED container with no override — deliberately, because "a proof that quietly passes different flags proves a different image" — so it serves no door at all, and the apply failed there while ten other jobs passed. I had measured only the run that had the flag, which is the run I happened to have in hand. It now asks the emulator which doors it serves (GET /_feint/s3) and decides from the answer. That is this repository's own rule, already written in projectStarters for this exact job: branch on a declaration, never on a name you expect. The decision is recorded in SOS_DOOR rather than inferred from the variable, and that is not tidiness. An assertion keyed off its own input cannot report the bug where that input went missing — #459's shape, a suite skipping itself. Three executions measured by hand, because one would have proved nothing: - no door at all: a note, the bucket is left out, the stack still applies, verdict 0 — this is the image job; - three doors: the inventory assertion holds, verdict 0 — this is the matrix; - a served door with the export emptied: the leg FAILS, naming the door it looked in. This is the one that proves the guard cannot skip itself. A note on the harness rather than the subject, since it cost two wrong readings. A `head -3` on the output of stacks.sh truncated before the exoscale stack was even reached, so three `ok:` lines from the SCALEWAY stack read as a guard that had passed. Before that, a sed whose pattern did not match left the subject unchanged and the test reported on nothing. Both were caught by asking why a result looked impossible rather than by the gates. mise run prepush green. shellcheck clean. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…, so macOS can run it (#849) `TestRunKeepsItsAuthorityOwnerOnly` failed on both macOS runners, and on nothing the test exists to measure: it ran `stat -c '%a %n'`, which is GNU, and the BSD stat on macOS answered `illegal option -- c` with its usage. The run directory was 0700 the whole time. Rewritten POSIX-only, and stronger than before rather than merely portable: the child now `cat`s its authority to /dev/null, so exit 0 proves the file is READABLE by the client process — the property proxy.WriteCA's deliberate 0644 exists for, and which the old form only inferred from a mode string. The directory's owner-only mode is read from `ls -ld`, which every Unix spells `drwx------`. The question still comes from inside the child, through the variable the child was actually given. A test that stat'd a path the test itself computed would be checking its own arithmetic, and that is why it is written this way. Measured, not assumed: `grep -rn 'stat -c'` finds no other use in the repository, so this was the only GNU-only invocation. The nine mutations of tools/falsify/specs/run-lands-a-client-without-privilege.json all still bite, which is what proves the rewrite did not soften the assertion. Green on Linux locally; the macOS half is proved by the two jobs that failed, not by me. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ther than copied (#849, #262) Two strangers' stacks from examples/stacks/surveyed.md were replayed against this branch, chosen because the register names them as the two the Object Storage data plane could change. Both moved, and the replay found one defect in feint that no test had. ## The defect The sequence both commands document — eval `feint env exoscale`, then `feint run -- terraform apply` — was refused by its own binary: feint run: refusing to run with credentials that are not known fakes: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, EXOSCALE_API_KEY, EXOSCALE_API_SECRET `knownFakes` was a second, hand-written copy of values the packs already publish through `Pack.Env`, and the two had drifted: the pack publishes a 23-character EXOSCALE_API_KEY, the list held a 25-character one, and the 40-character secret was in neither. The operator who followed the two commands' own notes was told their credentials were real. The fix removes the second list rather than correcting it. `acceptedFakes` reads the packs — the same source `feint env` prints — so the two cannot drift again. knownFakes stays for the placeholders no pack publishes. It fails closed: if the packs cannot be built, the accepted set shrinks, and a refusal names the variable and offers --unsafe-real-credentials. TestEveryPublishedFakeIsAcceptedByRun asserts it from the packs rather than from a copy of their values, because a copy is exactly what drifted. It carries a denominator (a loop over nothing would pass) and asserts the refusing half, so it cannot be satisfied by accepting everything. /falsify: the mutation neutralises the derivation so the accepted set falls back to knownFakes alone, and the test goes red. Neutralised rather than deleted, because the harness requires every name in `find` to survive into `replace`. ## What the two replays measured **ioandev/scaleway-flatcar-k3s** @85cb05d: 11 resources applied against every earlier run's 9. The bucket and the uploaded qcow2 land in the local scaleway/fr-par door instead of taking a 403 from the real s3.fr-par.scw.cloud, and the object reads back sha256-identical. Re-plan `0 to change`, so scaleway_object carries no permanent diff. The chain now stops one resource later, on a refusal whose stated reason is no longer true: createSnapshot refuses an import because "this emulator does not serve Object Storage", while the object is in the door. Destroy not measured, and recorded as missing rather than assumed clean. **PhilippeChepy/platform** @84791e3: both SOS backup buckets reach the local de-muc-1 door, where every earlier run took a 403 from the real host. Nothing was configured for it: the aws provider builds that endpoint in code, and `feint run` intercepted it. The control-plane half is not comparable with the August figures, because the repository's lock file pins exoscale/exoscale at 0.49.0, below the v0.71.0 floor, so every /v2/ write is refused by user agent (#644) — the guard doing its job on a stranger's stack. One gap named rather than invented: aws provider 5.4.0 reads GetBucketEncryption after a create and the door answers 501; no recording of that shape exists for SOS. Both entries are written into examples/stacks/surveyed.md in the register's own form, with their harness inputs and what was NOT measured. No request left the station in either replay: feint run refuses every host that is not a door mounted by the emulator it was pointed at. mise run prepush green. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ck applies whole (#850, #262) ioandev/scaleway-flatcar-k3s, from examples/stacks/surveyed.md, now applies **19 resources of 19 with a second plan reporting no changes**, against 9 at every replay since August. It is #850's first Done-means criterion — one surveyed Scaleway stack that no longer escapes to real Object Storage — held by a stranger's stack rather than by an example this repository wrote. Two defects stood between 9 and 19, and neither was visible to a unit test. ## A refusal that outlived its reason `instance/v1/API.CreateSnapshot` with `{bucket, key}` was refused unconditionally, telling the client that "importing a snapshot reads Object Storage, which this emulator does not serve" — while the object sat in the local door, named by the request being refused. The stack had just uploaded it through `scaleway_object`. Seven resources hung behind that one call, every one a plain dependency: the image on the snapshot, three servers on the image, three private NICs on the servers. So the fix is worth more than its size. It is served now, and it checks rather than assumes: - the object is there: its size becomes the snapshot's size; - the object is absent: refused, as upstream refuses it; - no byte store armed: refused, naming `--objectstore` and `--s3-doors` rather than the limits page, because the operator can act on that. The refusal's DIALECT stays unmeasured and docs/limits.md says so: the real API's shape for a missing object is not recorded here, so it borrows this route's own invalid_arguments envelope. ## An empty base volume, which only the stack could find The first version left the route's default `{id, name}` in place. Every unit test passed. The stack's second plan then answered `2 to add, 0 to change, 2 to destroy`: the provider reads an empty base as `volume_id "fr-par-1/"` and calls that `forces replacement`, taking the image cut from it along. An imported snapshot has no parent, so the field has to say null. This is the create-then-read round trip the pack skill names, and the reason the measurement had to be a real client and not an assertion. ## One reader, two products block/v1's import is wired to the same reader. #807 documented the divergence in as many words — "the real API refuses an object that does not exist, and this handler does not check the opt-in byte store" — and leaving it there while fixing the instance side is precisely the shape CLAUDE.md warns about: the same lifecycle written twice and fixed once. With a store armed, both read the object and both refuse its absence. With none, block keeps #807's intention and instance refuses, because their prior contracts differed and silently aligning a served route would change behaviour for an operator who armed nothing. That difference is asserted, not assumed. `s3.StoreNamespace` replaces the hand-composed door scope so a pack cannot drift from what a door writes — a drift that would be silent, the pack simply never finding an object that is there. Its holding test spells the scope out by hand on purpose: a witness that computed it the same way as the subject would agree with any format. ## Tests and falsification - TestASnapshotImportTakesTheSizeOfTheObjectItNames - TestASnapshotImportIsRefusedWithoutTheObject (three absent populations, plus the unarmed refusal naming its flags) - TestAnImportedSnapshotHasNoBaseVolumeAndReplansEmpty, which also asserts that an ordinary snapshot still HAS a base volume, so it cannot be satisfied by nulling the field for everybody - TestABlockImportRefusesAnObjectTheDoorDoesNotHold tools/falsify/specs/a-snapshot-import-reads-the-object.json: three mutations, all compiled, all biting. docs/limits.md gains the instance-import section, closes the #807 divergence on the armed side, and records a third measurement from the same replay: a door answers 501 on `encryption`, which AWS provider 6.72.0 never reads and 5.4.0 reads after every bucket create. Both ends measured, so the limit is written with its version rather than as a blanket. mise run prepush green. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GO-2026-6859: an HTTP/1 server crash on a MaxBytesReader header race in net/http, found in go1.26.10 and fixed in go1.26.11. Nothing in this repository is the cause and nothing here can fix it; the pin is what moves, and every file that names a patch moves together — TestTheGoWorkflowPinsTheVersionMiseDoes holds that, and passes. Seventeen pins in eleven files. The historical comments in go.yml are left alone: they record why the pin moved each time, and rewriting them would erase the only account of it. Verified rather than assumed: `govulncheck ./...` on go1.26.11 answers "No vulnerabilities found" locally. Before the bump it reported GO-2026-6859 through net/http in the standard library. ## Why this was found by CI and not before the push This is the fourth time: 1.26.6, 1.26.9, 1.26.10, now 1.26.11, each a red on pull requests that touched no Go file. govulncheck runs in go.yml and nowhere else — not in `mise run prepush`, not in .pre-commit-config.yaml. That absence is correct, and the reason is CLAUDE.md's own argument for keeping conformance out of the hook, in a sharper form. prepush is offline, deterministic and a few seconds long. govulncheck is none of the three: it queries vuln.go.dev, so it fails on a network it cannot reach, and its verdict changes without a line of this repository changing. A gate that red teaches `--no-verify`, which disarms every other hook at once. So the fix is not to move it into the hook. A scan whose verdict moves on its own belongs on a SCHEDULE — the shape drift.yml already has for the upstream surface, and night-report.yml for a scan that could not conclude. Until that exists, this red lands on whoever happens to push next, which is what it did all four times. That is written in go.yml beside the pin, and filed. What this commit adds meanwhile is `mise run vulncheck`, so the question can be ASKED before pushing by whoever wants to ask it, and so the answer to "why did CI see this and I did not" is one command rather than a workflow file. Not done here, and named rather than silently skipped: go1.27.4 is released, and this repository's own rule is to work on the latest major. Moving a minor is a decision with its own measurements (golangci-lint, the toolchain a contributor installs, every pinned job), not a line to change inside a security patch. mise run prepush green. actionlint clean. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Unreleased section becomes `## [0.15.0] - 2026-11-11`, which is step 1 of RELEASING.md and the thing every generated install block reads for the version it tells a reader to download. commitizen's rule gives the number: 19 `feat` and 9 `fix` since v0.14.0, no `!` and no BREAKING CHANGE anywhere, so the minor moves and 0.x holds. Two entries added for work that landed after the section was first written, and both are the kinds of change this file's own header says deserve a line whatever their size — a response shape a client can observe, and a limit that moved: - the instance snapshot import from Object Storage, with what it checks and what stays unmeasured; - the two defects a surveyed third-party stack found: `feint run` refusing the fakes `feint env` publishes, and an imported snapshot answering an empty base volume that made the Scaleway provider replace it on every plan. `mise run docs:coverage` regenerated README.md, README.fr.md and docs/install.md in the same commit, as that step requires: the heading is where their pinned download URLs get their version, and leaving them behind makes `feint docs --check` exit 2 at the hook, on the pull request, in the preflight and inside the release workflow. Six hand-written files follow the same number, which nothing generates and TestEveryExampleNamesTheReleasedVersion holds: the three examples, the compose file, the breaking-configuration issue template and the demo tape. They were once wrong in both directions at once, telling a reader to install a release that did not exist. `mise run release:surface` is green: one change this release must carry, named — the withdrawn refusal on `exoscale/exoscale/v2.list-sos-buckets-usage` — zero excused, zero neither. `tools/release/unnamed.json` is empty, so step 5 is a no-op. mise run prepush green. Assisted-by: Claude Code (claude-opus-5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
Summary
One local S3 data plane, mounted on the port the emulator already serves, behind
a door per provider and region (
/_feint/s3/<provider>/<region>/). Bytes arewritten by
internal/core/objectstore, which the core owns and no pack knows;internal/s3speaks the protocol; each pack declares only what its own APIpublishes — the host its SDK builds, the region names it uses, and whether it
publishes an endpoint at all.
Three clients drive it end to end: the AWS CLI,
octlagainst the OUTSCALE OOSdoor, and Terraform. The bytes are compared by digest after a restart, which is
the only claim worth making about a store.
Alongside it,
feint run(#849) lands a client whose cloud endpoint is compiledin. It takes no hostname — that is the point, and the safety property: an
off-loopback target is unspellable. It reads the doors the running emulator
actually serves from
/_feint/s3, mints a short-lived authority in a 0700directory it verifies rather than creates, and hands the child four proxy
variables.
feint doctorreports a leftover authority and decides by the owningPID, so a run in flight is not reported as debris.
What the measurements decided, rather than taste
The hardcoded Scaleway Terraform path was called unreachable, and the premise
was wrong. Three documents said so. The reasoning was that redirection needs
DNS; it does not — an HTTP forward proxy receives the hostname in the client's
own CONNECT line. The old reasoning is kept in
docs/limits.mdunder its ownheading, with the error named, because a limit that quietly disappears teaches
nothing. A witness now holds the claim:
TestScalewayTerraformStorageThroughFeintRunOnTheHostNetworkdrives apply, asecond plan and destroy through
feint run, and reads 139,264 identical bytesback through the local door. It refuses to run inside a namespace rather than
pass vacuously.
An ETag is not served on simple PUT, and so there is no
aws_s3_objectin theexample stack. The object applies and its bytes land identical, but every
later plan wants to update it in place: the AWS provider compares the ETag a
read returns against
filemd5. Serving one means persisting it, a v5 byteformat older binaries could not read. The choice was between that migration and
an example carrying a permanent diff, and a permanent diff in an example teaches
a reader that drift is normal here. Written down in
docs/object-storage-routing.md, not left implicit.Two OUTSCALE discovery shapes are missing, and they are named.
GetBucketOwnershipControlsandGetBucketTaggingare unrecorded; the dooranswers the two that are. No shape is invented here, so the gap is a written gap
rather than a guess.
What the repository's own gates caught, and what CI caught that they could not
defect feat(account,images,conformance): a declared project catalogue, a resolve that only prints what boots, and every listen verdict waiting for the port it judges (#572, #476) #605 repeated exactly, so the fix is not three edits but
TestEveryConformanceEmulatorArmsTheSameObjectStore, which now also joins thethree
--s3-doorslists and fails when they drift.disabled object store answered 503 and the probe read it as a defect. It now
carries
X-Feint-Not-Emulated(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) and the probe skips a marked 5xxonly — an unmarked 5xx is still a fault, and a marked 4xx is still a read
refusal. Coverage was proved not to shrink: 100 operations probed armed
against 98 unarmed.
that runs the container as published, with no override.
stacks.shnowqueries
/_feint/s3and leaves the bucket out when no door answers — and theassertion keys off that answer, not off the variable being set.
The third one is the population trap this repository already documents: a
verdict about "this run" has to be proven on the poorest run that triggers
it. The poorest run here is the
imagejob. The richest is the one on my owndesk, which is why the mistake was easy.
CI on this branch was red for four jobs and nobody could have known, because
conformance.ymlruns onpushonly formain. I found it by dispatching theworkflow to check a job I had just added. The last run on
b8dfc6dis green onall ten jobs,
imageandfieldsincluded.What is NOT done, and why
#850 asks for six things. Five are met; the sixth is bounded, not generated,
and this pull request does not pretend otherwise — it closes no issue.
Criterion 59 wants the capability declaration generated.
capability.goadmitsone axis, a client against a pack, with one mode and one proof kind; this is a
data-plane capability for a pair already marked
supported. Generating the rowwould mean a second axis and a new proof kind for a single row — the resolver
#644 deleted because nothing rested on it. So the row in
docs/confidence.mdiswritten as opt-in and tied to its instrument by a test
(
TestTheHardcodedEndpointRowIsOwnedByAnInstrument): if it ever stops sayingno, it must name the witness, CI must run it, and the witness must exist in
the file it names. That prevents the defect the criterion was aimed at. The
generation itself is a separate change.
What was run, and what was named rather than run
mise run testplanprinted 125 changed paths and a long plan. Played onb8dfc6d:mise run prepushgreen (check,docs:check,drift:check,shapes:check)mise run conformanceon the assembled set, exit 0, 263.96s, score 386 of411 routes proven by a real client. The four machine-runtime suites printed
SKIP: no machine runtime, which is Three quarters of the conformance workflow is a client's retry back-off on refusals the emulator answers in 30 ms #459's deliberate behaviour: a suite thatcannot measure says so rather than passing silently
mise run lint-shell,mise run falsify:lint(1554 mutations across 232specs, every declared fragment still applies)
conformance.ymlon this exact commit, all ten jobs green,imageandfieldsincludedNamed rather than run, because a reviewer cannot tell a considered omission
from a forgotten one:
FEINT_VM=incus-ovn mise run conformance:leg -- runtimeandconformance:functional. The plan routes on an import ofinternal/core/machine, not on a change to it: this diff adds no driver pathand touches no network, address or firewall. Starting containers is a side
effect on the operator's station, so it is asked for rather than taken
mise run falsify:allis the night pass and was launched separately; thespecs that mutate files this diff touches are its subset, and
falsify:lintalready proves every declared fragment still matches its file
mise run limits:checkexits 2 on this branch and onmain: ten sectionsof
docs/limits.mdcite an issue that closed after anyone last read them.That is Documentation : réexaminer les 10 sections limits signalées, sans rafraîchir les acknowledgements en masse #857, tracked with exactly these ten sections, and it is deliberately
not refreshed here.
limits-acks.py refreshwould re-date all of them andturn a freshness signal into a lie; none of the ten is the S3 section this
branch touched, and the gate runs in the nightly drift workflow, not on pull
requests
Type of change
Checklist
Always
mise run checkpassesgo.modis unchangedinternal/corestill knows no provider.objectstorestores bytes underopaque keys; the provider spelling lives in
internal/s3/doors.goand ineach pack's profile
s3.Profileis the field list that proved it: a host, a region, anaddress key — and
Boot.User's precedent for the case where a providerdoes not fit
mise run testplanwas run and what it printed was runWhen a model wrote a substantive part of this
Assisted-by:trailer names the tool and the model version, on everycommit. No
Co-Authored-Byfor a tool, per CONTRIBUTING.mdmise run conformancerun on the assembled set, not per commita run of the real client. The two shapes for which that is not true are
not served, and are named above
When a route is added or changed
mise run conformancepasses(
--contracts contracts)/_feint/conformance | jq .unread_request_fieldsreadcoverage/committed with the changeWhen behaviour a client can observe changes
docs/limits.mdupdated — including the three statements it had wrongWhen
.github/workflows/is touched# vX.Y.Zcommentharden-runneris the first step of the newhardcoded-endpointjobpermissions:least-privilegeteeWhen a machine runtime is involved
N/A — no machine runtime in this change. The object store writes bytes to a
directory and the doors are HTTP.
Related issues
Refs #850 — five of six criteria met, 59 bounded rather than generated.
Refs #849, #858.
Does not close anything: the remaining criterion and the two unrecorded OOS
shapes are real, and an issue closed on five sixths is an issue nobody reopens.
🤖 Generated with Claude Code