fix(scaleway): the account's projects answer, four response shapes match the cloud, and a block volume stops hanging its server's delete (#372, #366, #367, #368) - #576
Merged
Conversation
…ery VPC stack evaluates first stops being a wall (#372) Two published third-party stacks that walk the VPC surface died in two exchanges each, both `GET /account/v3/projects`, 501. `data "scaleway_account_project"` is evaluated ahead of every resource in a Terraform graph, so one unserved route made a whole category of stack unreachable: no VPC, no ACL, no private network, no gateway, no IPAM address downstream of it was ever attempted. Reproduced here before the change, with the real client on 4699: $ scw account project list -o json {"message":"feint does not serve /account/v3/projects; …", "error":{"type":"not_emulated"}} $ curl -s -o /dev/null -w '%{http_code}' .../account/v3/projects 501 and after it, the pair the provider's own read walks: $ scw account project list name=default -o json [{"id":"11111111-…","name":"default","organization_id":"99999999-…", "created_at":"2025-01-01T00:00:00Z","description":"cannot_be_deleted",…}] $ scw account project get project-id=11111111-… -o json {"id":"11111111-…","name":"default",…} TWO ROUTES, NOT ONE, AND THAT IS A CORRECTION TO THE ISSUE. #372's "done means" asks for ListProjects because that is what its recording shows. The provider's DataSourceAccountProjectRead (read 2026-08-27) calls ListProjects only when a name is configured and ALWAYS calls GetProject afterwards, so serving the list alone would have moved the wall one call down the road. The source is the provider, not the recording, because the recording stopped at the first refusal. Where every value comes from, per rule 4: · the shape of Project and of ListProjectsResponse: the SDK, api/account/v3/account_sdk.go — eight fields, qualification a pointer (null), status a value ("active"); · the project's name `default` and its description `cannot_be_deleted`: Scaleway's own published document for the product, whose ListProjects description carries a worked response where the organization's first project is exactly that. No recording in corpus/ carries an account/v3 exchange, so the document is the only measurement available and the commit says so rather than inventing a name; · `organization_id` required and never compared: required is the document's own `required: true`; not compared is the rule listSSHKeys already carries with its measurement — `scw` names its configured organization on every list and nothing obliges it to spell ours. The list FILTERS and the read RESOLVES, and the asymmetry is deliberate: an empty list is a truthful answer to "which of your projects is called X", while a 404 on the read is a wall one call after the one this issue removes. GetProject therefore echoes any identifier, which is docs/limits.md's "Identifiers are not checked against anything" applied to the value a stack is most likely to carry over from production. The same section now states the cost: a stack whose project_name is not `default` fails on FindExact, and the fix is to point it at `default`. Bringing the product under the gate is the other half, and it is what the issue means by "not simply one more route": `account` joins tools/drift/gate.sh, so its twelve operations arrive untriaged and every one of them now has an answer — two served, ten declined with a reason (project writes, because a project is not a record here; the contract signatures, because a signature is a legal act on a paying account). `tools/contract/scaleway-products.txt` gains the product, and the portal path is measured rather than guessed: /account/v3/schema.yml answers 404 while /account/project/v3/schema.yml answers 200. RE-EXTRACTING THE CONTRACT PULLED IN REAL vpc/v2 DRIFT, and it is in this commit because the extraction is whole-document. Scaleway renamed the S3-endpoint family to *ObjectStoragePrivateAccess on 2026-08-25 — the operations were triaged in the pack the same day, the contract was not — and the query parameter went with them. Both spellings now narrow (objectStorageFilter), the way parsePage already reads per_page and page_size, because a client built before the rename still sends the old one. What this commit does NOT touch is the same rename on the response body, where the SDK now declares object_storage_private_access_enabled and this pack still answers s3_integration_enabled: that is a divergence of its own and belongs to its own measurement. Driven by the real clients: `scw account project list/get` in scw-cli.sh, and `data "scaleway_account_project"` first in the Terraform fixture — first in the file because that is where a data source runs, which is the whole shape of this defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…clares it, and seven exemptions go (#367) `image.from_server` is an empty STRING on the wire and this emulator answered null, on GetImage and on the image every server answer embeds. Reproduced with the real client on 4699, off the wire rather than off the CLI's re-serialisation (which hides the difference by decoding into the SDK's own string): $ curl -s …/instance/v1/zones/fr-par-1/images/2222… | jq .image.from_server null # before "" # after The source is threefold and the SDK settles the type: `Image.FromServer` is declared `string`, a value and not a pointer (instance_sdk.go:1375), so the wire form for an image built from no server is the zero value the SDK round-trips. Two independent recordings of a real fr-par account carry exactly that — corpus/scaleway/scw-instance.jsonl (2026-08-21) and corpus/scaleway/scw-billed-shapes.jsonl (2026-08-24) — and this pack's own clientImageView, for an image a client cuts, had always written the string. One pack, two image doors, two answers for one field. The measurement that says it landed is the gate's own staleness rule: `feint corpus --check` now reports these seven exemptions as excusing nothing, so they are deleted rather than left to rot. That deletion is the proof, and it is why the entries and the fix travel together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd an attached address carries its gateway and its tags (#366, #368) Fused, and the reason is the file rather than convenience: both defects are the server answer, both are graded by the same three operations of the same two recordings (CreateServer, GetServer, UpdateServer), and both retire entries from one JSON block whose staleness rule refuses a half-edit — an exemption that excuses nothing fails the gate, so the code and the deletion cannot travel in different commits. #366, and the DECISION the issue asked for: SERVE, not decline. "bootscript": null, "extra_networks": [] Both are keys the cloud writes and this emulator omitted entirely, which is a different answer from writing them empty: a client reading server["bootscript"] finds a present null upstream and no key here. The source is the recordings and it cannot be the SDK — `type Server` declares neither field any more (instance_sdk.go, read 2026-08-27) — so this is the case clientImageView already documents on the image, "here because a recording said so, not because the SDK did". DeclinedFields() was the other answer and it does not fit: it is for a field this emulator cannot answer truthfully, and the whole content of these two is a constant two independent recordings state. #368, one function and four places. serverIPView renders Server.public_ip AND every element of Server.public_ips, so one omission was reported four times in each of three operations — which is exactly why the issue refused to be split into two. "gateway": null -> "203.0.113.1" "tags": [] -> the address's own tags The gateway is not an invented constant. flexibleBlock is 203.0.113.0/24 and allocateFlexibleAddress reserves its first two addresses already, on the stated ground that "the runtime answers on the first usable address" — so 203.0.113.1 is the one address no flexible IP can ever be handed, and the one a machine on that block routes through. One block, one gateway, stable between two reads. The tags are a COPY of the stored list, not the stored list: a response map outlives the call that built it, and handing a caller a window onto the store is how a mutation of an answer reaches the record. The test mutates the answer and re-reads the address to prove it does not. Both stored shapes are read — []string from a live create, []any from a restored snapshot — for the reason hasEveryTag already states next door. Reproduced before, with the real client on 4699, off the wire: 366 bootscript key present : False 366 extra_networks present : False 368 public_ip.gateway : None 368 public_ip.tags : [] and after, on the same sequence (a tagged flexible IP reserved, then a DEV1-S wearing it): 366 bootscript : None, and the key is present 366 extra_networks : [] 368 public_ip.gateway : '203.0.113.1' 368 public_ip.tags : ['feint-corpus'] The gate is the measurement that says it landed. `feint corpus --check` went from 497 knowingly-accepted findings to 425 with zero unaccepted, and the thirty entries deleted here are the ones it reported as excusing nothing — named by the run, not by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…goes, so `scw instance server delete` stops hanging (#365) Found while measuring #365, and it predates it: `volume_type = "sbs_volume"` has been honoured since #8, and every server created with one left its root volume reading `status: in_use` for the life of the emulator. `references` emptied correctly — it is computed, and a gone server leaves none — while the status, which is the stored state, never moved. That difference is not cosmetic. `scw instance server delete` polls the volume the server's own answer named until it settles, so the CLI NEVER RETURNED. Measured on 2026-08-27 with scw 2.56.3 against a binary built before this change: server efdd7bac-…, sbs root 55df1c04-… before delete -> status: in_use references: 1 scw instance server delete rc=124 in 20s # it never returned after delete -> status: in_use references: 0 and five identical `GET /block/v1alpha1/zones/fr-par-1/volumes/55df1c04-…` in the CLI's own debug trace, one per poll. After it, the whole recorded cloud sequence completes: before delete -> status: in_use references: 1 scw instance server delete rc=0 in 0s DELETE block volume -> 404 # the CLI removed it itself Two causes, both here. `volumesOf` walked instance/v1 alone, so a block volume a server held was never released by either door to a gone server — DELETE /servers/{id} or the terminate action. And `detachStoredVolume` had nothing to say about state, which is right for an instance volume (it is available whatever holds it) and wrong for a block one, whose `status` IS its attachment. What made this invisible for as long as it lived: no default takes that path. Every suite that deletes a server deletes one whose root volume is an instance/v1 one, so the only way in was an explicit `sbs_volume` in a create body, which only the #8 unit test writes — and that test never deletes its server. THIS IS NOT #365's FIX, and the issue stays open. #365 asks for the DEFAULT root volume of a DEV1-S to be a block volume, which is what the cloud gives one. That was tried here and reverted, with the reason written where the condition is: it moves every server's root disk out of instance/v1, where this pack implements the entire server-volume relationship — attach-volume, the update's volume map, CreateSnapshot, GetVolume and DeleteVolume all resolve kindVolume alone, and the guard that stops one server stealing another's root volume stops covering the root volume at all. Eight unit tests encode that arrangement. It is a decision about where a root disk lives, and it is the maintainer's to take; this commit removes the trap waiting at the end of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…372, #367, #366, #368, #365) One spec for the four commits before it, because `falsify:all` is keyed by the files a diff touches and these guards live in five of them. Every mutation neutralises a condition or narrows a value; none deletes a term, so a red verdict is the test answering about the guard rather than about a build that no longer compiles. Replayed on 2026-08-27: thirteen compiled, thirteen bit. One of them did not, at first, and it is worth keeping written down because it is the failure mode this harness exists for. The order_by mutation originally neutralised the handler's `if !ok { return }`, and the test stayed GREEN: orderAsked writes the 400 itself, so skipping the return leaves the refusal already on the wire and only adds a superfluous second write. The guard was fine and the mutation was aimed at the wrong line — which, on a spec nobody replayed, would have read for ever as a guard proven. It now flips the enum the handler declares (`|| true`), which is where the decision actually lives, and it bites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st carry (#372, #366, #367, #368, #365) release:surface refuses a newly served operation nobody named, and these two are the first account/v3 routes this emulator mounts. The Fixed entries carry the same rule for the other three: a response shape a client can observe is what this file exists for, and four of them moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…silence nobody questioned
An `evidence:update` pass died on this, on 2026-08-27:
FAIL: 10.186.0.20 is unreachable inside one private network;
the segment is broken, not isolated
The base of that tree had been measured green on the same assertion twenty
minutes earlier, so the sentence was accusing the product's dataplane on the
strength of a connection that did not open — with nothing having established
that the responder ever existed.
`shared/verdicts.sh` has stated the rule since #219 and offers both helpers: a
machine that never booted, or whose listener never started, produces exactly the
observation a broken segment produces. `scaleway/network.sh` asks its own guard
before concluding. `exoscale/network.sh` asked only the far network, and the
comment where the control belonged argued its absence: "no separate wait for the
responder — the verdict below is a REACH, so polling it covers the listener
coming up as well". That is true only while the segment carries traffic; when the
reach fails the two become indistinguishable, which is the one case the sentence
is about.
`outscale/network.sh` had the same hole twenty lines from where it had the fix:
the Vm of the *other* Net answers itself before the isolation verdict, the Vm of
the *same* Net did not before the reachability one. One lesson, learned in one
suite of three and carried to neither.
Both now ask. And the count is held rather than remembered:
TestEveryReachabilityProbeIsBackedByAPositiveControl reddens when a suite gains
a probe without gaining a control, which is the regression that happened twice.
Three mutations, all biting.
Two things the guard cannot see are written into it rather than left to be
discovered: it counts identifiers, so a control that is present but wrong
satisfies it; and its `probes == 0` branch cannot be falsified under a harness
that requires every name in `find` to survive into `replace`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…utscale operations catch up Regenerated by `mise run evidence:update` on this branch, both legs green, 1057 s, zero FAIL — the first pass of that task to complete since #574 was fixed. What moved, and none of it in the shrinking direction: - `account/v3/ProjectAPI.ListProjects` and `.GetProject` gain their rows, `driven: true` and `contract: clean`: a real Terraform provider drove them. Both declare `shape: "unobserved"` and `negative: false` honestly — no real answer of theirs has been recorded and no refusal of theirs observed. - `osc/Client.AcceptNetPeering` and `osc/Client.DeleteNetPeering` move `behaviour: false → true`. Not this branch's doing: the record was last written at `ce254db` (2026-08-24) and the only commit touching `tools/conformance/outscale/octl.sh` since is `7453d88` (2026-08-25, #460/#462), which names those two operations ten times. The record is catching up with an assertion that has existed for two days and had never been replayed — itself a measure of how long this task had gone unrun. - The `contracts` and `suites` hashes move, as they must: #372 re-extracts a contract and #574 rewrote a suite's header. The run also proved, end to end, that #574 is gone in the population that triggered it: under `--vm incus`, the mode this task's runtime leg defaults to, `ok: the probe reaches the guard on their shared network (10.186.0.20)` — the assertion that was red two hours earlier. And the leg announced its mode at second zero, which is #574's own repair being exercised for the second time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat the record just earned `mise run evidence:update` moved `osc/Client.AcceptNetPeering` and `osc/Client.DeleteNetPeering` to `behaviour: true`. Both declared `Route.Unearnable` on that axis, with the reason that a deleted peering stays readable so the store never sees the destruction half of a lifecycle. internal/cli's TestAnUnearnableAxisIsNotAlreadyEarned refused the pair by name before this commit existed: "a reason that outlived its cause is read as a decision". The cause is #460/#462, which taught outscale/octl.sh to drive those two through their states on 2026-08-25; nothing had replayed the record since. Reject and Read keep the declaration, and that is a narrower claim than it looks — which is written at the site rather than left to be assumed. The record has not contradicted them, and that is not the same as the reason holding: no client drives either one yet, so their axis is **unearned** where this says **unearnable**. The gate cannot tell those apart. The declaration stays where nothing has falsified it, and now says why it is suspect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #372. Closes #366. Closes #367. Closes #368.
#365 is not closed — it is answered, and the answer is a decision recorded as
#571. See the last section.
Five findings about what a real Scaleway client sees, delivered as one branch
because they share a pack, a proof, and a nature: what this emulator answers
differs from what the cloud answers, and a real client can tell.
Reproductions, with the real client,
FEINT_VM=offscw account project list501 not_emulateddata "scaleway_account_project"reads through under real Terraform 2.81.0bootscript/extra_networksnulland[]image.from_serverNone'', on GetImage and on the server's inline imagepublic_ip.gateway/.tagsNone/[]'203.0.113.1'/['feint-corpus']The corpus gate is the offline half of the same measurement:
feint corpus --checkwent 497 → 425 knowingly-accepted findings, 0 unaccepted, and the37 exemptions it reported as excusing nothing were deleted — driven by the
gate's own output, not by a hand-written list.
The source of every field changed
Rule 4 applies and is answered field by field:
from_server: ""— SDKinstance_sdk.go:1375,FromServer string, a valuerather than a pointer, plus both recordings. This pack's own
clientImageViewalready wrote the string; only the catalogue view disagreed.
bootscript: null,extra_networks: []— the recordings only. The currentSDK
type Serverdeclares neither. That is the decision Scaleway: a server answer omits bootscript and extra_networks, which the cloud writes as null and [] #366 asked for, andit went to serve rather than decline:
DeclinedFields()is for a field theemulator cannot answer truthfully, and the whole content of these two is a
constant that two independent recordings state.
gateway: "203.0.113.1"— this pack's own address plan:flexibleBlockis203.0.113.0/24andallocateFlexibleAddressalready reserves its first two,so
.1is the one address no IP can be handed.tags— the address's own stored tags, copied.api/account/v3/account_sdk.go;name: "default"anddescription: "cannot_be_deleted"from Scaleway's own publishedaccount/project/v3schema, since no corpus recording carries anaccount/v3exchange. The commit says so.#372's own "done means" was wrong on one point
Measured in the provider's source:
DataSourceAccountProjectReadcallsListProjectsonly when a name is configured and always callsGetProjectafterwards. Serving the list alone would have moved the wall one call. Both
routes are mounted.
One live limit written into
docs/limits.md: a stack whoseproject_nameis notdefaultfails on the provider's ownFindExact. The list filters honestly;only the read echoes an unknown identifier. Filed as #572.
#365: stopped, measured, and it produced two results instead of one
The contradiction the brief feared does not exist. Measured with
scw -D2.56.3:
scw instance server create type=DEV1-S image=ubuntu_jammysends novolumesmap at all, sorootVolume's default decides, a block root sums tonothing local, and
volumes_constraint.min_sizestays out of it.A larger one does.
sbs_volumemoves the root disk out ofinstance/v1,where this pack implements the entire server-volume relationship:
attach-volume, the update's volume map,CreateSnapshot,GetVolumeandDeleteVolumeall resolvekindVolumealone. A client's own root disk stopsbeing reachable through any of them, and
TestAttachingDoesNotStealAnotherServersVolumestops covering the root volume atall. Eight unit tests encode that arrangement. Reverted, with the reason
written at the condition, and the decision recorded as #571.
What shipped is the defect that attempt uncovered, and it predates it. A
block volume was never released when its server went —
referencesemptied whilestatusstayedin_usefor ever — soscw instance server deletepolled ituntil the client gave up:
Proved against a binary built from
HEADbefore any change.volume_type = "sbs_volume"has been honoured since #8, so that hang was reachable by anybodywho asked for it.
The record was regenerated here, and it grew
mise run evidence:update, both legs green, 1057 s, zero FAIL — the firstcompletion of that task since #574 was fixed.
account/v3routes gain their rows:driven: true,contract: clean,and an honest
shape: "unobserved"/negative: false. That is what unblockedTestEveryMountedOperationHasAnEvidenceRow, and with it this whole branch.osc/Client.AcceptNetPeeringand.DeleteNetPeeringmovedbehaviour: false → true, and not because of this branch: the record waslast written 2026-08-24 and the only commit touching
outscale/octl.shsinceis The Outscale suite drives an archived client: migrate to octl, which honours the endpoint and does not retry a 409 #460/feat(conformance): the Outscale suite drives octl, and no operation was lost in the move (#460) #462 on 2026-08-25, which names them ten times. The record is catching
up with an assertion two days old that nothing had replayed.
That move then falsified a declaration, which is the part worth reading. Both
operations declared
Route.Unearnableon the behaviour axis, with the reasonthat a deleted peering stays readable so the store never sees the destruction
half of a lifecycle.
internal/cli'sTestAnUnearnableAxisIsNotAlreadyEarnednamed the pair by hand: "a reason that outlived its cause is read as a
decision". Removed.
RejectNetPeeringandReadNetPeeringskeep the declaration, and the site nowsays why that is a narrower claim than it looks: the record has not contradicted
them, which is not the same as the reason holding. No client drives either
one yet, so their axis is unearned where the declaration says unearnable —
two different things, and the gate cannot tell them apart.
One instrument repaired on the way
exoscale/network.shandoutscale/network.sheach drew a verdict about asegment from a connection that did not open, with nothing establishing that the
responder had ever existed.
scaleway/network.shhad asked since #219;shared/verdicts.shstates the rule in its own preamble and offers both helpers.Both now ask, and
TestEveryReachabilityProbeIsBackedByAPositiveControlreddenswhen a suite gains a probe without gaining a control. Three mutations, all
biting. Its own two blind spots — it counts identifiers, and its
probes == 0branch is unfalsifiable under a harness that requires every name in
findtosurvive into
replace— are written into it rather than left to be discovered.That repair is what let the diagnosis of #574 proceed at all: without it, the
verdict said "the segment is broken" about a listener nobody had asked.
Gates
prepush0 ·conformance:leg -- probe,-- scw-cli,-- octl,-- fields(352/375 routes, omission gate over 316 operations),
conformance:environment,conformance:zones— all green ·mise run conformance(--vm off) green ·evidence:updategreen under--vm incus, including the Exoscale reachabilityassertion that was red two hours before this branch shipped · falsify: the
lot's own 13 mutations and the reachability spec's 3, all biting;
falsify:lintover 135 specs.
What was not obtained
tf-scaleway-modules/terraform-scaleway-networkcompleteexample was notdriven to its next wall, so Scaleway: every third-party VPC stack dies on /account/v3/projects before it reaches a VPC path #372's last bullet is unproven. What is proven is
the same data source under the real provider in the conformance fixture.
s3_integration_enabledalongside its newname is not established — filed as Scaleway renamed the VPC object-storage field on 2026-08-25 and the response body still answers the old name: the query parameter was fixed, the answer was not #570, with the reason it was not folded
in here.