Skip to content

feat(s3): one local object-storage data plane behind three provider doors, and a client that lands on it (#850, #849) - #863

Merged
stephrobert merged 29 commits into
mainfrom
feat/850-object-storage-release
Nov 11, 2026
Merged

stephrobert merged 29 commits into
mainfrom
feat/850-object-storage-release

Conversation

@stephrobert

Copy link
Copy Markdown
Owner

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 are
written by internal/core/objectstore, which the core owns and no pack knows;
internal/s3 speaks the protocol; each pack declares only what its own API
publishes — 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, octl against the OUTSCALE OOS
door, 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 compiled
in. 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 0700
directory it verifies rather than creates, and hands the child four proxy
variables. feint doctor reports a leftover authority and decides by the owning
PID, 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.md under its own
heading, with the error named, because a limit that quietly disappears teaches
nothing. A witness now holds the claim:
TestScalewayTerraformStorageThroughFeintRunOnTheHostNetwork drives apply, a
second plan and destroy through feint run, and reads 139,264 identical bytes
back 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_object in the
example 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 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. Written down in
docs/object-storage-routing.md, not left implicit.

Two OUTSCALE discovery shapes are missing, and they are named.
GetBucketOwnershipControls and GetBucketTagging are unrecorded; the door
answers 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

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 image job. The richest is the one on my own
desk, which is why the mistake was easy.

CI on this branch was red for four jobs and nobody could have known, because
conformance.yml runs on push only for main. I found it by dispatching the
workflow to check a job I had just added. The last run on b8dfc6d is green on
all ten jobs, image and fields included.

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.go admits
one 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 row
would 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.md is
written as opt-in and tied to its instrument by a test
(TestTheHardcodedEndpointRowIsOwnedByAnInstrument): if it ever stops saying
no, 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 testplan printed 125 changed paths and a long plan. Played on
b8dfc6d:

  • mise run prepush green (check, docs:check, drift:check, shapes:check)
  • mise run conformance on the assembled set, exit 0, 263.96s, score 386 of
    411 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 that
    cannot measure says so rather than passing silently
  • mise run lint-shell, mise run falsify:lint (1554 mutations across 232
    specs, every declared fragment still applies)
  • every leg of conformance.yml on this exact commit, all ten jobs green,
    image and fields included

Named rather than run, because a reviewer cannot tell a considered omission
from a forgotten one:

  • FEINT_VM=incus-ovn mise run conformance:leg -- runtime and
    conformance:functional. The plan routes on an import of
    internal/core/machine, not on a change to it: this diff adds no driver path
    and 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:all is the night pass and was launched separately; the
    specs that mutate files this diff touches are its subset, and falsify:lint
    already proves every declared fragment still matches its file
  • mise run limits:check exits 2 on this branch and on main: ten sections
    of docs/limits.md cite 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 refresh would re-date all of them and
    turn 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

  • A route added or changed
  • Bug fix
  • Refactor
  • Documentation
  • Chore / tooling
  • Security / supply chain

Checklist

Always

  • mise run check passes
  • No new external Go dependency — go.mod is unchanged
  • internal/core still knows no provider. objectstore stores bytes under
    opaque keys; the provider spelling lives in internal/s3/doors.go and in
    each pack's profile
  • Nothing in the diff could be written identically for another provider.
    s3.Profile is the field list that proved it: a host, a region, an
    address key — and Boot.User's precedent for the case where a provider
    does not fit
  • mise run testplan was run and what it printed was run

When a model wrote a substantive part of this

  • An Assisted-by: trailer names the tool and the model version, on every
    commit. No Co-Authored-By for a tool, per CONTRIBUTING.md
  • mise run conformance run on the assembled set, not per commit
  • Every field name comes from the provider's SDK, their OpenAPI document or
    a 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

  • Every route declares its upstream operation at the SDK's exact name
  • mise run conformance passes
  • Responses validated against the provider's own API description
    (--contracts contracts)
  • /_feint/conformance | jq .unread_request_fields read
  • coverage/ committed with the change

When behaviour a client can observe changes

  • docs/limits.md updated — including the three statements it had wrong
  • Generated README tables regenerated

When .github/workflows/ is touched

  • Every action pinned to a full 40-character SHA with a # vX.Y.Z comment
  • harden-runner is the first step of the new hardcoded-endpoint job
  • permissions: least-privilege
  • No job renamed. One job added, so the required checks may want it
  • The exit-code contract holds; no gate piped through tee

When 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

stephrobert and others added 29 commits November 6, 2026 22:25
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>
@stephrobert
stephrobert merged commit 019a946 into main Nov 11, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant