Skip to content

fix(store, scaleway): what a pack stores is what a snapshot gives back, and the VPC body answers the name Scaleway publishes today (#567, #570) - #581

Merged
stephrobert merged 6 commits into
mainfrom
fix/548-567-570-what-it-declares
Aug 28, 2026
Merged

fix(store, scaleway): what a pack stores is what a snapshot gives back, and the VPC body answers the name Scaleway publishes today (#567, #570)#581
stephrobert merged 6 commits into
mainfrom
fix/548-567-570-what-it-declares

Conversation

@stephrobert

Copy link
Copy Markdown
Owner

Closes #567. Closes #570.

#548 stays open, with its third path measured, working, and deliberately not
shipped — the last section says why, and it is the part of this branch worth
reading first.

#567 — the issue's premise was wrong, and the measurement says so

The model pack now stores the JSON shape through named doors, and
TestTheFourthPacksNodeKeepsWhatItWearsAcrossASnapshot asserts behaviour:
snapshot, restore into a fresh store, reboot on a revived pack, and read what the
runtime was actually asked for.

The issue says the three real packs are immune. They were not.
storetest.GoShapes, run over Scaleway's own barrage, reported 82 resources
holding a []string in Attrs["tags"].

Nothing broke, which is exactly why it lasted: hasEveryTag and tagsOf each
carry a hand-written type switch tolerating both shapes#542's seven copies
of one tolerance, one storey down. Exoscale had the sharper form: two helpers for
one job, labelsToAttr right and labelsOrEmpty wrong. All three packs are
fixed — orEmpty answers []any, labelsOrEmpty is deleted rather than
repaired
, Outscale gains idsList.

The rule now lives in two places, because prose alone is what let this last:

  • beside Resource.Attrs — the six shapes a restore returns, the three
    measured losses, and why numbers are the one exception;
  • as storetest.GoShapes, registered in the shared barrage and wired into all
    three packs' barrage_test.go, among the controls
    TestEveryPackRunsTheSharedBarrage discovers from a pack's own test sources —
    so a fourth pack fails a control it never had to write.

The maintainer's decision is respected exactly: the shared layer gained a
control, not a repair. Nothing in internal/core learned to rebuild a
[]Rule, which it must not (rule 5).

#570 — the new name alone, and three gates that were all honestly silent

What the body answers: the new name, and only it.
.upstream/scaleway-openapi/vpc-v2.yml — the product's published schema, fetched
2026-08-28 — carries object_storage_private_access_enabled and
has_object_storage_private_access in each schema's x-properties-order, the
document's own exhaustive property list, and not the old names. The SDK
agrees (vpc_sdk.go:1024, :646). No deprecation alias is declared anywhere,
so answering both would have invented one. A test asserts the old spelling is
gone, and its falsification is a body that answers both.

The issue names the VPC half only; the private network was renamed in the same
move and is fixed too.

What the 2026-08-20 recording now means: historical for the spelling and
for nothing else
. It still settles that the field is present on every answer
and false on an empty account. That split is written at the assertion, in
corpus/accepted.json (20 entries keyed (file, operation, kind, path), so each
names exactly the field it excuses), and in one DeclinedFields() entry —
one, because the offline shapes store holds a default VPC and no private network,
so ListVPCs is the only recorded operation that gate reaches, and a decline
that excuses nothing fails.

What else moved: nothing. mise run contract:update produced an empty
diff
. The issue says the contract was never re-extracted; it was, for #372, and
already carried both new names. Only the pack lagged.

The blind spot, named rather than implied

A rename passes between three gates, and each is honest in its own population:

  • drift:check compares operations, not field names — green for any rename;
  • the omission gate lists fields declared and recorded — the new name is
    declared but unrecorded, so it has nothing to miss;
  • the contract check refuses undeclared fields on closed schemas only, and
    Scaleway closes none: 0 of 632.

Three silent gates, three populations, a rename between all of them. Recorded
here because nothing in this branch closes it.

#548 — a third path, measured, working, and not shipped

The defect still reproduces from the API alone under --vm incus-ovn: a server
created with public_ip, private NIC attached after, ends with
eth0 nictype=routed ipv4.address=203.0.113.2 and no security.acls, beside
eth1 on the managed network carrying its group.

The premise was re-measured first: Incus 7.2 refuses security.acls on a routed
NIC
Invalid device option "security.acls". So #337 holds and the remedy has
to move the address.

Both refusals already in docs/limits.md come from one place — the host route the
routed NIC owns. The third path takes the address off the device without taking
the device off the instance
, which is neither of them, and Incus 7.2 accepts it
on a running instance: ipv4.address= empty, then the /32 into
feint-uplink's ipv4.routesaccepted, where it answered file exists
before — then ipv4.routes.external on the managed NIC.

Coverage, with a listener on both ports so that a refusal cannot be an empty
port, and a third port with no listener as the negative control:

203.0.113.2:443   connect_ex=0    OPEN      a rule opens it
203.0.113.2:80    connect_ex=111  refused   a listener is there, no rule is
203.0.113.2:8080  connect_ex=111  refused   no listener: the negative control

Before the migration, port 80 on that address was OPEN. Outbound survives
(ipv4.nat=true, ping 1.1.1.1 answers from inside, the station as control).

It is not shipped, and both reasons are measurements written into the section:

  • What a machine "answers on" would move. Incus.Inspect takes the first
    global IPv4 of the lowest-named interface. After the migration both addresses
    share eth1 ({"eth0":[],"eth1":["10.199.0.2","203.0.113.2"]}, stable over
    three reads), so Binding.Address / Started.Address would report the private
    address where they reported the public one. Scaleway is unaffected — its API
    answered public_ip from its own store throughout — but Binding.Address is
    the shared layer, and the Exoscale pack reads it.
  • Only OVN was measured. Bridge mode routes through ipv4.routes on the
    device instead, and nothing ran under --vm incus.

So the bound stands, functional.sh goes on skipping the public half, and
docs/limits.md now carries a sequence with numbers instead of "a sequence
exists on paper". Shipping it would have moved the problem from the firewall to
the address the API publishes, which is not a fix.

Gates

mise run conformance whole suite, one emulator, FEINT_FIELD_GATE=1 — green,
267 s, 352/375, no omission failure · FEINT_VM=incus-ovn conformance:leg -- runtime — green, 639 s, doorstep clean both ends · conformance:environment
green · prepush, docs:check, limits:check, falsify:lint (890 mutations,
144 specs) green.

Falsification replays on the five specs whose subject this diff touches —
attrs-json-shape (new, 9), private-network-ipv6 (8), corpus-findings (10),
stored-numbers (10), provider-four (13) — every mutation bites.

Two mutations did not bite at first, and in both cases the comment was wrong,
not the harness.
One was planted in a loop the barrage never enters, retargeted
into orEmpty's body. The other is #295's aliasing mutation, whose property had
moved: addressesOf now decodes map[string]any into a fresh map, so the
conversion is the copy. Replanted at JoinSegment, where aliasing is still
reachable, with the reason in its label.

Where testplan was wrong

Its note "a change to the stored shape is proved across a restart by
mise run conformance:environment"
is false: environment/up.sh contains
zero occurrences of snapshot or --state — it brings an emulator up and down
and never saves or loads state. What proves #567 across that door is the unit
round trip, not that leg.

It also earned the OVN runtime leg because provider-four/intents.go imports
internal/core/machine, though this diff changes no driver code. Correct by its
rule, expensive in practice; it was run because #548 asked for it.

What was not obtained

stephrobert and others added 6 commits August 28, 2026 13:44
…d it still holds (#505)

`mise run limits:check` went red on the `lb acl delete` section because #505
closed on 2026-08-28, after the 2026-08-27 acknowledgement. A closed issue is
not a lifted limit, and this one closed carrying `ships-as-a-limit`: it is
documentation, not a fix.

Read on 2026-08-28, and the two facts that would lift it are both still false:

- `scw version` on this station answers 2.56.3, the version the section names;
- `interceptACL` in `scaleway-cli/v2@v2.56.3` still guards its pre-fetch with
  `argsI.(*lb.ZonedAPIDeleteCertificateRequest)` where the argument is a
  `*ZonedAPIDeleteACLRequest`, so `getACL` is nil and the success path
  dereferences `getACL.Frontend.LB.Tags`.

Only that section's line is re-dated. `limits-acks.py refresh` re-dates all
fifty, and the tool says of it "This says somebody looked. It is only true if
somebody did" — fifty were not read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ee real packs were not immune (#567)

The maintainer's decision of 2026-08-27 settles where the model pack's half
goes: `testdata/provider-four` is a pack to copy, so it stores the JSON shape
like a real pack, and the shared layer does not learn to rebuild a `[]Rule`.
That half is here. The half that survives it is the rule, and the rule is now
a control rather than a habit.

## What the model pack did, and now does

Measured on 2026-08-27 through `store.Snapshot` then `store.Restore` into a
fresh store: `[]Rule` came back `nil`, `[]string` came back `[]any`,
`map[string]string` came back `map[string]any`, and every reader asserted the
Go type. A restored node wore no barriers, joined no segments and had no
address, while the API went on describing all three.

It now writes through named doors - `rulesAttr`, `stringsAttr`,
`addressesAttr` and their readers - and the Go types stay in its own
signatures, where they are useful.
`TestTheFourthPacksNodeKeepsWhatItWearsAcrossASnapshot` asserts the behaviour
rather than the shape: it snapshots, restores into a fresh store, reboots on a
revived pack, and reads what the runtime was actually asked for.

## The issue's premise was wrong, and the measurement says so

The issue states the three real packs are immune because they already store
`[]any` and `map[string]any`. `storetest.GoShapes`, run over Scaleway's own
barrage on 2026-08-28, reported 82 resources - security groups, snapshots,
volumes and a VPC - holding a `[]string` in `Attrs["tags"]`. Nothing was
broken by them, and that is why they lasted: `hasEveryTag` and `tagsOf` each
carry a hand-written type switch tolerating both shapes, one per file, which
is #542's seven copies one storey down. Exoscale carried the same defect in a
sharper form - two helpers for one job, `labelsToAttr` right and
`labelsOrEmpty` wrong, so an instance stored the JSON shape and a pool, a
block volume and a load balancer did not.

So the three packs are fixed too: `orEmpty` answers `[]any`, `labelsOrEmpty`
is gone rather than repaired (a second door is a door the next author picks by
proximity), and Outscale's `SecurityGroupIds` go through a new `idsList`.

## The rule, in the two places somebody meets it

Prose beside `Resource.Attrs`, where an author decides what to put in it: the
six shapes a restore returns, the three measured losses, and why numbers are
the one exception (`resource.Number` has an answer, a `[]Rule` has none
`internal/core` may know, rule 5).

`storetest.GoShapes`, beside `Sweep`, for the same reason `Sweep` lives there:
the invariant is the core's, the traffic is the pack's. It is added to the
controls `TestEveryPackRunsTheSharedBarrage` discovers from a pack's own test
sources, so a fourth pack fails a control it never had to remember to write.

The control is tested in both directions: it names the three measured shapes
plus one hidden a hop inside a legitimate container, it stays silent on every
numeric width and every JSON shape a pack legitimately writes, and its wording
is checked against a real Snapshot/Restore rather than against a belief about
`encoding/json`.

Population, stated rather than implied: `GoShapes` judges what a barrage
produced, exactly like `Sweep`, so a write on a path no barrage drives is
outside it. The source survey that found the exoscale and outscale sites is
what covered those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, and the recording that predates the rename says so where it is read (#570)

Scaleway renamed the vpc/v2 Object Storage family on 2026-08-25. The query
parameter already accepted both spellings; the response body still carried the
old one, on both objects.

## Decision 1: the new name alone, and it is the document that says so

`.upstream/scaleway-openapi/vpc-v2.yml`, the VPC v2 product's own published
schema, fetched 2026-08-28:

  scaleway.vpc.v2.VPC.object_storage_private_access_enabled     (:796)
  scaleway.vpc.v2.PrivateNetwork.has_object_storage_private_access (:552)

and each schema's `x-properties-order` — the document's own exhaustive property
list — carries the new name and not the old. The SDK agrees
(`vpc_sdk.go:1024` and `:646`). Nothing upstream declares a deprecation alias,
so answering both would invent a window no source declares, which is the one
thing rule 4 forbids. A test now asserts the old spelling is gone rather than
merely that the new one is present, and its falsification is the body that
answers both.

The issue names the VPC half only. The private network was renamed in the same
move and this fixes both; `has_s3_integration` was live on four doors.

## Decision 2: the 2026-08-20 recording is historical for the name, and for nothing else

It is evidence of what a real fr-par account answered five days before the
rename, and re-recording needs a paid account this repository does not have.
What it still settles is what a rename does not touch: the field is present on
every answer, and its value is false on an account with nothing attached.
Nothing else in this repository establishes either. What it no longer settles
is the spelling.

That split is written at the assertion (`vpc_measured_test.go`), beside the
code (`vpc.go`, both views), and in the two artefacts that replay the
recording:

- `corpus/accepted.json` gains 20 entries, one per (file, operation, kind,
  path), each naming exactly the field it excuses on the operation it excuses
  it on. The staleness rule deletes them the day vpc/v2 is recorded again;
- `DeclinedFields()` gains one entry, and one only. The offline shapes store
  holds a default VPC and no private network, so ListVPCs is the single one of
  the fourteen recorded vpc/v2 operations whose element shape that gate
  reaches, and a decline that excuses nothing fails.

## Decision 3: nothing else moved, measured rather than assumed

`mise run contract:update` on 2026-08-28 produced an empty diff. The issue
says the contract was never re-extracted; it was, for #372 on 2026-08-27, and
it already carries both new names. The pack was the only thing lagging.

## The blind spot, named because it stays

`mise run drift:check` is green and would be green for any rename: it compares
operations, not field names. So would the two field gates, each for its own
honest reason. The omission gate (#88) lists a field both declared upstream and
observed in a recording, and the new name is declared but unrecorded, so it sits
in the "unconfirmed" list rather than in "missing". The contract check refuses an
undeclared field only where the document closes the schema, and Scaleway closes
none — the extraction reports "0 closed" for all 632. Three silent gates, three
different populations, and a rename falls between all of them. What caught this
was somebody reading the SDK beside the pack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, and it works (#548)

#548 asks for the enforcement half: a server created WITH its flexible IP keeps
an unfiltered routed NIC beside the private one the group covers. The honesty
half shipped with #573; this is the measurement the enforcement half was
waiting on.

## It still reproduces

Under `--vm incus-ovn`, on 2026-08-28, from the API alone: a group whose inbound
default is drop with one rule for 443, a server created with `public_ip`, its
private NIC attached afterwards.

  eth0: nictype=routed, ipv4.address=203.0.113.2, no security.acls
  eth1: network=fnt-0da7a7bda1e, security.acls=scw-892dbc3d91e

## A third path, and it is neither of the two docs/limits.md already refuses

Both refusals came from one place, the host route the routed NIC owns. The
third takes the address off the device without taking the device off the
instance, which Incus 7.2 accepts on a running instance:

  1. `incus config device set <m> eth0 ipv4.address=` -> the host route is gone,
     and the device stays, so the profile eth0 on incusbr0 is never unmasked;
  2. the uplink then takes the /32, where it answered `file exists` before;
  3. `ipv4.routes.external` on the managed NIC, which is `routeAddressOVN`'s own
     gesture, and the guest repair that function already performs.

## The coverage is real, and the probe tells the two answers apart

A listener sits on both ports, so a refusal cannot be an empty port:

  203.0.113.2:443   connect_ex=0    OPEN      a rule opens it
  203.0.113.2:80    connect_ex=111  refused   a listener is there, no rule is
  203.0.113.2:8080  connect_ex=111  refused   no listener: the negative control

Before the migration, 80 on that address was OPEN. The machine keeps its way
out: ipv4.nat=true on the OVN network, and ping 1.1.1.1 answers from inside with
the station as the control.

## Why the driver is not changed in the same breath

Two measurements, not reluctance, and both are in the section:

  - what a machine "answers on" moves. Incus.Inspect reports the first global
    IPv4 of the lowest-named interface, and after the migration both addresses
    share eth1: the runtime answers {"eth0":[],"eth1":["10.199.0.2",
    "203.0.113.2"]}, stable across three reads, so Binding.Address would report
    the private address where it used to report the public one. Scaleway is
    unaffected, publishing the flexible IP from its own store, and it answered
    public_ip: 203.0.113.2 throughout, but Binding.Address is the shared layer
    and the Exoscale pack reads it;
  - only OVN was measured. The bridge mode routes a public address through
    ipv4.routes on the device instead, and nothing here ran under `--vm incus`.

So the bound in the table stands and functional.sh goes on skipping the public
half. What changed is that "a sequence exists on paper" is now a sequence with
numbers, and #548 has a remedy to implement rather than a question to answer.

No code changed: the station was swept after the probe, and the emulator
unwound its own uplink route on terminate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ved to, and the shape control says what it cannot see (#567, #295)

Replaying `tools/falsify/specs/provider-four.json` after the Attrs change
reported one mutation STILL GREEN, and the harness was right:

  the fourth pack writes a segment address into the map the store still shares,
  outside the lock and outside the conditional write-back (#295, #517)

The property did not stop holding, it moved. The mutation removed the copy
inside `withAddress`, and that copy is no longer what holds it: since #567,
`addressesOf` decodes a stored `map[string]any` into a fresh
`map[string]string`, so the conversion IS the copy and nothing `withAddress`
does can reach the store's map any more.

So the mutation is planted where aliasing is still reachable — `JoinSegment`,
writing straight into `res.Attrs["addresses"]`, which `resource.Clone` shares
with the store — and its label carries the reason it moved, so the next reader
does not rediscover a green that means "the subject left".
`TestTheFourthPacksNestedAttributesAreNeverWrittenThroughTheStore` is red under
it again, and all thirteen of that spec's mutations bite.

Also: `storetest.GoShapes` now states what it cannot see, where the next reader
meets it. It judges what a barrage produced, exactly like `Sweep`, and that
blind spot has already been paid once — its first run named 82 Scaleway
resources and said nothing about the Exoscale pools, block volumes and load
balancers storing a `map[string]string` on paths no barrage drives. Those were
found by reading the packs' sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ince it accepts both (#570)

The line named only `s3_integration_enabled`, which Scaleway retired on
2026-08-25. `listquery.go` narrows on either spelling, deliberately, so a
client that has not been rebuilt is still served; naming one of the two made
the sentence half true the day the rename landed. It now names both, and says
why the *body* is the other decision: a request tolerates a name a client still
sends, an answer may not invent a deprecation alias no upstream source
declares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant