feat!: v3.0.0 - the target axis, a mandatory org, centralised products, declared boundaries #7
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/v3-target-axis-and-mandatory-org"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Seven major decisions, grouped on purpose: they land on names both consuming repositories call final, and two migrations a month apart would be paid for twice.
Six commits, and one of them is where the suite goes green
A grammar change cannot be staged with a green suite at every step — the registry is the source every other file derives from. Commits 1 to 4 are red by construction; the suite goes green in commit 5 and stays green. Saying so beats one unreviewable commit.
registry— the grammar, the vocabularies,products.yaml,forgejo.yaml, the VMID, the three exception classes,organization_axis,tenancymodule— the assembly, the target lookup over two registries, the compact style, the product resolutiontools—decodewith long forms, the three bijection lints, the audit requalificationpolicy— the regexes, the VMID positions, the exception classestests— 53 cases, 19 portability, 23 audited, 11 plans, 11 lint mutations → greendocs— CONVENTION.md, the changelog, the guidesThe two decisions worth arguing about
targetis ONE position drawing from TWO closed, disjoint registries — entities, and theshortcodes ofproducts.yaml. One slot answers whom do I serve whether the consumer is a tenant or another product, and a test asserts the code spaces do not intersect: decoding is positional, so a code in both would make the middle of a name undecidable.Two shapes were rejected and the reasons are in
CONVENTION.mdbecause someone will propose them again:entity.dataseeds) adds a second separator to a grammar that has one, so everysplit("-"), every policy rule andnaming-check.shgrow a branch;shr, the slot names a product") makes the meaning of segment 3 depend on the value of segment 2, so a name stops being readable left to right. It is also false: a runner that builds the forge isprdwhile its target lives inshr.orgis always present, and the operator of the SUBSTRATE never is. Who runs a machine is a property of the substrate, declared in the consumer's registry — a name carrying it would change the day the substrate changes hands, on names that are final.Four bugs the corpus caught, two of them mine from earlier in this branch
%drefuses one:sprintfreturned"%!d(float64=1.301201e+06)". As the key intovmids.yamlthat matches nothing, so every VMID was reported unreserved. Five digits stayed integral and hid it completelybad_tag_valuesat the canonical values only, excludingvar.extra_tags. The guard that refuses a.in a tag value caught it in the same runcheck()and the Rego hashed regex refused a name the module producesmax_lenis tight the org leaves no component, andorg-env-domain-hashhas no middletoks[0]is the org now, not the env — a silent misread, had the corpus not had a caseThe costs, measured rather than estimated
⚠️ One portability case BREAKS.
prd-agt-runtime-sgwas exactly 18 characters, so a Proxmox security group truncates while its AWS and Scaleway siblings do not. Scope: 5 of the 3451 standard types are too tight fororg-env-domain-component-slug, 3 of them even without a target. Recorded intests/portability.yamlwith that reason, and in the README's known limits.What was measured, not assumed
tofu show -jsonexposes no lifecycle block at all — noprevent_destroy, nocreate_before_destroy(OpenTofu 1.12.6). So the audit is told which addresses are protected, and then says the rename will be refused at plan time rather than reporting a warning the plan will not honour.configuration…expressions.<attr>.references; a state exposesdepends_on. The collector reads both — verified against real documents, because it returned nothing on the existing fixtures.conftestdoes exposeproducts.yamlasdata.products, sotarget_codesresolves. Had it not, everystandard_regexwould have been built from an empty alternation.The tests that make the new rules non-decorative
tests/lints.py— 11 mutations, 0 silent.lint-registrybeing green proves nothing; the question is whether it would cry out. Each mutation breaks the registry in memory: a value with no long form, two values sharing one, two products on one pair, and the one the whole segment rests on — the two target registries intersecting.tests/audit/references/— captured from a real plan, and its three resources ISOLATE each claim. The previous commit admitted the reference requalification was inspecting an empty set. Now: a cluster cited by a pool (C from the reference alone, A on its own merits), a pool cited by nobody (keeps its class), a vpc declared protected (C from the declaration alone). Mutation-tested both ways — remove the reference and the cluster falls to A; remove the declaration and the vpc falls to A.The four real plans were RE-CAPTURED, not substituted, against the actual providers.
plan_compliantwas regenerated from the tool, because its whole claim is "produced by the tool, therefore compliant" and patching it by hand would leave that claim green and false.Verification
make checkgreen: 70 module tests, 50 policy unit tests, 11 reference plans, 53 shared cases, 19 portability cases, 23 audited resources, 11 lint mutations,english: 0 findings,address: 25 pins confronted, 0 findings.What this release deliberately does NOT do
The kit is still consumed by
?ref=, not by a vendored copy. Vendoring is the better answer — offline, in a worktree, no credential, and no bootstrap circularity for a repository that creates the forge hosting its own convention. It changes the consumer contract, and doing half of it would leave two sources of truth where there is one today. Stated in the README's known limits.Consumer-side migration is not here either (
.naming-version, the recomputed VMIDs, theproducts.yamlentries): that belongs toseeds-infraandhome-node-infra, each in its own pull request, after this one is tagged.Open before tagging v3.0.0 — ANSWERED on 2026-10-28
sit,frg,run,pfm,idy,fnd,atl. Final from their first use onward.per's long form ispersonal, confirmed. The registry already said so, so nothing changed here — it closesseeds-infraaction 52, which renamespersoon the consumer side.7c1e5d5makes that a DECISION rather than a deduction.What the third answer changed
The location was already in a Scaleway bucket name, but it got there through
scope: global, which the type declares because the documentation says a bucket name is unique worldwide. Nobody here has put that to the API — which is exactly why the assessment left the point open.So the segment stops resting on the claim. Both types whose uniqueness scope is documented rather than measured now ALSO declare
location_in_name: true:scaleway_object_bucketscaleway_registry_namespaceTwo reasons now force one segment, so neither is load-bearing alone. Deliberate, and written in the notes: a mutation of either stays silent, and what must never happen is a correction of the scope silently shortening a name that cannot be renamed.
Each reason measured on its own:
No generated name changes, suite still green. The decision half of open point 1 is closed in
decisions/2026-10-27-scaleway-assessment.md; the measurement stays open, with its date and provider version still to record — the two notes still assert something nobody has verified.🤖 Generated with Claude Code
THE FIRST OF SEVEN COMMITS FOR ONE ATOMIC CHANGE. A grammar change cannot be staged with a green suite at every step: the registry is the source every other file derives from, so the module, the policy, the workshop tool, the test corpus and the examples are red from here until the sixth commit. The branch tip is green; the middle is not, and saying so is better than a single unreviewable commit. SEVEN DECISIONS ARE MAJOR, which is why they travel together rather than as two migrations a month apart, on names both consuming repositories call final: a new vocabulary axis, renamed org codes, a segment-appearance rule, the VMID formula, the word for the axes, the site/location rename, and the widening of that axis from an entity to a target. order: [org, env, target, domain, component, qualifier, slug, location, instance] org IS ALWAYS PRESENT NOW. It was placed only on worldwide-unique names, which left every other name unable to say who owns it. And the rule that goes with it: the operator of the SUBSTRATE is never in a name. Who runs a machine or an account is a property of the substrate, declared in the consuming repository's registry - a name carrying it would change the day the substrate changes hands, on names that are final. target IS ONE POSITION DRAWING FROM TWO CLOSED REGISTRIES - entities, and the "short" codes of registry/products.yaml. One slot answers "whom do I serve" whether the consumer is a tenant or another product, and a test asserts the two code spaces DO NOT INTERSECT, because decoding is positional and an ambiguous middle token is unreadable. Two shapes were rejected and the reasons are worth keeping. A TYPED PAIR (entity.dataseeds) adds a second separator to a grammar that has one, so every split("-"), every Rego rule and naming-check.sh grow a branch; it spends sixteen characters where three suffice; and the dot is not accepted by every resource type, so the grammar would hold on only some of them. A CONDITIONAL RULE - "when the env is shr, the slot names a product instead of an entity" - makes the MEANING of segment 3 depend on the VALUE of segment 2, so a name stops being readable left to right. It is also false: a runner that builds the forge is a production machine, env prd while its target lives in shr. env and target are independent, and the registry says so. LOCATION, RENAMED FROM SITE, and it follows the scope now: a name unique beyond the account carries its location (location_in_name_for_scopes). The asymmetry decides it - a useless location segment is harmless, its absence forbids a second regional deployment. The old word was taken by a product name, and the rename is free inside a major that is already happening. THE VMID IS SEVEN DIGITS, DECIDED ONCE, because a VMID is allocated and never reused so widening it later renumbers everything. The target position exists and holds 0 while unused. The v2 formula carried neither the org nor the target, so two orgs sharing an env and a domain competed for the same number. THREE EXCEPTION CLASSES instead of a single dated one. A platform-imposed suffix is not debt - renewing it every six months is a ritual with no object - and a legacy name expires on an EVENT, not a date: grandfathered is cleared by the object's next recreation and must name that recreation. TWO THINGS THAT WERE ASSUMED ARE NOW DECLARED. organization_axis says whether each environment gets its own top-level compartment, and the exception SWAPS A GUARD rather than removing one: with "single", env becomes the only separator between state stores and a test then requires it. tenancy says a stack may be rented only from an shr compartment, and what crosses, crosses by a public path - nothing crosses a Scaleway Organization in IAM, measured 2026-10-13. ALSO HERE: provider_class (cloud | named-non-cloud | utility), declared on the 19 utility entries rather than inherited, so the "cloud" default never applies to something that governs no name. And registry/types/forgejo.yaml, without which a stack managing a forge does not plan at all - rule 5 blocks an unknown type. AND TWO WORDS LEAVE tests/french.txt. The widened list filed "pays" and "visible" as French; English spells both, and the guard put them in front of me on the first run over text I had just written. That is the third and fourth time that triage was wrong and the guard caught it - which is the argument for having built it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>SECOND OF SEVEN. The registry declares the grammar; this is the file that produces a name from it. The policy, the workshop tool, the test corpus and the examples are still red. THE ASSEMBLY, and one line carries most of the change: org-env-[target-]domain-component[-qualifier]-slug[-location][-nn] compact() drops empty strings, so an absent target and an absent location remove their own segment with NO CONDITION WRITTEN. That is why context.target is "" rather than null: a null would need a branch at every use, and a branch is where the two cases drift apart. THE TARGET LOOKUP TRIES BOTH REGISTRIES IN ORDER - vocabulary.entity, then the "short" codes of products.<repo>. A code found in neither leaves the lookup null, and a precondition refuses it NAMING BOTH VOCABULARIES. It has to refuse rather than pass the token through: decoding is positional, so a reader has no way to tell an unknown target from an extra component. THE VMID REFUSES A MISSING DIGIT RATHER THAN READING IT AS ZERO. Only entities carry a digit today; a product in the target slot has none allocated. Treating that as 0 would hand out a number that collides with "no target at all" - forever, on an identifier that is allocated once and never reused, where a collision cannot be repaired by renaming. That precondition was WRONG WHEN FIRST WRITTEN, and the smoke test said so inside a minute: it fired on a Scaleway server named for a product target, which computes no VMID at all. Unscoped, it would have made the target segment unusable outside Proxmox - which is most of it. It now applies only where some resource actually computes a VMID. THE ORG IS IN THE COMPACT STYLE TOO, at the front, the operator's own foundation included: a compact name that omitted it would be the one place the v3 rule did not hold. Eight characters then hold four one-character codes plus four of component, and that limit is written where someone will meet it. var.product SUPPLIES THE PAIR, resolved against context.repo because "platform" exists in two repositories with different pairs. A precondition refuses product AND an explicit pair together: both at once is not a convenience, it is two sources that can disagree, and which one wins would be an implementation detail. AN EMPTY VALUE EMITS NO TAG AND NO LABEL. The value regex refuses the empty string, and a tag that says nothing is worse than an absent one - target is the case, on every resource serving nobody but its own org. TWO THINGS THE TEST HARNESS TAUGHT, both worth keeping: - An error_message is evaluated even when its condition PASSES, so an interpolation of a null variable fails the whole output rather than the assertion. coalesce() does not help: it refuses a null argument too. - My own expectation was wrong once - a Scaleway server's slug is "vm", not "srv" - and the module was right. Reading the diff rather than trusting the expectation is the only reason that did not become a wrong test. modules/naming/tests/v3_grammar.tftest.hcl keeps the four cases the grammar exists for, beside the generated file rather than inside it: cases.tftest.hcl is regenerated from tests/cases.yaml, and a case whose point is a precondition or a products.yaml lookup has no column there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>THIRD OF SEVEN. The test corpus, the examples and the documentation are still red. STATED AT THE TOP OF THE FILE NOW: this tool is OUTSIDE THE CONTROL PATH. The HCL module, policy/naming.rego and naming-check.sh owe it nothing, so the image that runs pull-request code does not gain an interpreter because of it. What enforces the convention in CI is the Rego policy over a plan; this is the workshop. THE SAME ANSWERS AS THE MODULE, checked against it rather than assumed: generate --org see --env prd --target dsd --location par … see-prd-dsd-web-base-bkt-par generate --org see --env shr --location par --product forge see-shr-ops-forge-vm-01 vmid --org per --env lab --domain ops --instance 1 1400901 vmid --org see --env prd --target dsd --domain web … 2311001 vmid --target frg (a product with no digit) REFUSED decode RETURNS BOTH THE CODE AND THE LONG FORM in one answer - org/env/domain/target plus org_long, env_long, domain_long, target_long. Without it an inventory needs a second table, and a second table diverges from this one. A target that is a PRODUCT has no long form on the org axis, so its explicit name is the product table's own key, and decode says which repository it came from. THE TARGET IS RECOGNISED BY ITS REGISTRY, NOT BY ITS POSITION. The segment is optional, so the token after the env is either a target or the domain, and both vocabularies being CLOSED AND DISJOINT is exactly what makes that question decidable. The closed alternation is written into the standard regex too: a free token there would make three middle tokens impossible to split. THREE LINTS THE GRAMMAR DEPENDS ON, and they are not formalities - without them the long form and the product table are conventions nobody checks: - code <-> long is one to one on org, env and entity, and "short" is unique per axis. A long form with no code cannot be resolved; two codes sharing one cannot be told apart in the only places a long form is used - a path, a CLI argument, a forge organisation name. - products.yaml is bijective PER REPOSITORY, on the pair and on the short. Two products on one pair would be two stacks producing identical names. - THE TWO TARGET REGISTRIES MUST NOT INTERSECT, and neither may collide with an env or a domain code. This is the one the whole segment rests on. THE AUDIT REQUALIFIES BY REFERENCES (P20): any object CITED BY ANOTHER moves up to C, whatever its type. Renaming it does not merely recreate it, it breaks whatever points at it - and that second effect is what no per-type field can carry. The collector walks the whole document for depends_on and references rather than one known path, and OVER-collects on purpose: the conservative direction is the one that asks a human. MEASURED, BECAUSE I WAS ABOUT TO ASSUME IT. `tofu show -json` EXPOSES NO lifecycle BLOCK AT ALL - no prevent_destroy, no create_before_destroy. On OpenTofu 1.12.6, configuration.root_module.resources[] carries only address, expressions, mode, name, provider_config_key, schema_version and type. So a protected address is DECLARED in the mapping, and the tool then says the rename is REFUSED AT PLAN TIME rather than reporting it as a warning the plan will not honour. seeds-infra has 13 such sites. AND THE COLLECTOR FOUND NOTHING ON THE FIRST RUN, which is the failure this repository has a rule about. The audit fixtures are hand-written minimal states with no dependency data, so the requalification was inspecting an empty set and would have been green forever. Measured against real documents instead: a PLAN exposes references under configuration.root_module.resources[].expressions.<attr>.references, a STATE exposes depends_on on the resource, and the collector returns the same address from both. A fixture that actually carries a reference comes with the test corpus in the next commit - without it this requalification is untested, and an untested guard is a guard that is green. Also here, found while editing: seven more French strings this file was still emitting to a terminal, and one typo in a user-facing sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>FOURTH OF SEVEN. The test corpus, the examples and the documentation are still red - 17 of the 48 policy unit tests fail here, every one of them on a v2 name in a fixture. That is the next commit's work, and it is the commit where the suite goes green. THIS IS THE FILE THAT MAKES THE CONVENTION TRUE IN CI. The HCL module names things and the Python tool reports on them; this is what a pull request cannot get past. So the grammar change lands here in the same shape, not an approximation of it. THE ORG IS UNCONDITIONAL. org_part no longer branches on scope: it used to appear only for a worldwide-unique type, which left every other name unable to say who owns it. THE TARGET'S ALTERNATION IS WRITTEN OUT, not accepted as a free token, and that is the whole reason the segment can sit in the middle. target_codes is the union of the entity codes and the product shorts, as a SET so a code declared in two repositories appears once - "pfm" is that case. A free token there would make three middle tokens impossible to split, and a name would stop being readable left to right. THE LOCATION FOLLOWS THE SCOPE NOW, through location_forced: a type that asks for it, or a scope listed in grammar.location_in_name_for_scopes. The hint a reader gets when a name fails now reads org-env-[target-]domain-component[-qualifier]-slug[-location][-nn], which is the grammar rather than a paraphrase of it. THREE EXCEPTION CLASSES, and the class decides both what the entry must carry and what makes it stale. One dated class forced two wrong shapes: a platform-imposed suffix had to be renewed every six months - a ritual with no object - and a legacy name had to be given an expiry date nobody could pick. debt reason + expires, 183 days at most. What every v2 entry meant, which is why an entry with no class is read as this one. structural reason + source + measured_on, no expiry. It CLAIMS the platform forbids the rename, and that is the claim most often made without measuring, so it owes a source and a date. grandfathered reason + cleared_by, no expiry. It expires on an EVENT - the object's next recreation - so it owes the recreation that would clear it. The required fields are read from data.policy rather than written here as well: the registry declares them, and two copies would disagree. AND A WARN FOR A CONTRADICTION RATHER THAN SILENCE: a structural or grandfathered entry carrying an expiry date is reported. Whoever wrote that date believed the entry would lapse, and it will not. VERIFIED RATHER THAN ASSUMED: conftest does expose registry/products.yaml as data.products, so target_codes resolves - ['atl','dsd','fnd','frg','idy','pfm','run', 'sit']. Had it not, every standard_regex would have been built from an empty alternation and the failure would have looked like a fixture problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>FIFTH OF SEVEN, AND THE ONE THAT ENDS THE RED. make check passes: 70 module tests, 50 policy unit tests, 11 reference plans, 53 shared cases, 19 portability cases, 23 audited resources, 11 lint mutations, english and address both clean. THE 53 SHARED CASES WERE DERIVED BY RULE, NOT BY ASKING THE TOOL. Every expectation comes from applying the v3 grammar to the v2 one - the org always present, the target after the env, the location where the scope asks for it - and the table was read line by line before anything was written. Where the tool disagreed I derived the answer a second time by hand rather than accepting its output, which is how the five disagreements below turned out to be MY expectations being incomplete, not the code being wrong. FIVE REAL COSTS OF v3, each measured rather than estimated: see-prd-dsd-obs-langfusework-5f8d8 a 34-character budget truncates deeper see_prd_dsd_data_lake_sqlm_6ad6b 32 characters no longer hold the full name see-prd-dsd-agt-runtime-c7649 a 30-character service account loses its slug per-prd-agt-93397 an 18-character budget keeps NO component at all see-prd-dsd-data-base-pr-e5862 a GCP project_id needs 33 and accepts 30 And the one place v3 BREAKS PORTABILITY, which the portability suite would not let me pass over: "prd-agt-runtime-sg" was exactly 18 characters, so a Proxmox security group truncates while its AWS and Scaleway siblings do not. Measured scope: 5 of the 3451 standard types are too tight to hold org-env-domain-component-slug, 3 of them even without a target. The override carries that reason where somebody will meet it. FOUR BUGS THE CORPUS CAUGHT, and two of them were mine from earlier in this branch: - THE VMID RESERVATION LOOKUP WAS DEAD. Seven digits arrive from the plan JSON as a float64 and Go's %d REFUSES one: sprintf returned the literal "%!d(float64=1.301201e+06)". As the key into vmids.yaml that matches nothing, so EVERY VMID was reported unreserved - a reservation registry that could no longer be consulted. Five digits stayed integral and hid it completely. Now format_int. - THE EXTRA TAGS STOPPED BEING CHECKED. I had pointed bad_tag_values at the canonical values only, which silently excluded var.extra_tags. The guard that refuses a "." in a tag value caught it in the same run - which is the whole reason it exists. - check() AND THE REGO HASHED REGEX both refused a name the module produces: where max_len is tight the org leaves no component, and "org-env-domain-hash" has no middle. Refusing it would have meant the three disagreed about a name already written on a resource. - THE VMID POLICY READ THE NAME POSITIONALLY and toks[0] is the org now, not the env. A silent misread rather than a failure, had the corpus not had a case for it. THE AUDIT CORPUS NOW AUDITS A v2 ESTATE UNDER v3 RULES, which is not an oversight - it is the case every consumer meets on the day they bump. A name with no org no longer passes check(), so a resource that was compliant becomes one to rename and the class says what that costs. AND THE P20 FIXTURE THAT WAS MISSING. The previous commit admitted the reference requalification was inspecting an empty set: the audit fixtures are hand-written minimal states with no dependency data. tests/audit/references/ is captured from a real plan, and its three resources ISOLATE each claim - a cluster cited by a pool (C from the reference alone, A on its own merits), a pool cited by nobody (keeps its class), and a vpc declared protected (C from the declaration alone). Mutation-tested both ways: remove the reference and the cluster falls to A; remove the declaration and the vpc falls to A. THE FOUR REAL PLANS WERE RE-CAPTURED, not substituted - `tofu plan` and `tofu show -json` against the actual providers (scaleway 2.88.0, hashicorp/aws 6.70.0, hashicorp/google 7.46.2, bpg/proxmox 0.118.0). Reduced to resource_changes as before, for two reasons: the policy reads nothing else, and a full plan carries a timestamp that would make the fixture differ on every capture - a file that changes every time is a file nobody reviews. The five hand-altered plans cannot be re-captured and were transformed name attribute by name attribute; plan_compliant was REGENERATED from the tool, because its whole claim is "produced by the tool, therefore compliant" and patching it by hand would leave that claim green and false. tests/lints.py TESTS THE THREE v3 BIJECTIONS BY MUTATION, eleven of them, because a lint that passes proves nothing: the question is whether it would cry out, and the only way to ask is to break the registry in memory and watch. The one that matters most is the two target registries intersecting - without it, the middle of a name is undecidable. Also here: the consumer script goes through "$TF_BIN" at all four call sites; the workflow accepts a pinned image that REPLACES the download and verifies the publisher's SHA256SUMS for whatever it still fetches (integrity of the download, not provenance - stated in the file rather than implied); the exception template carries one worked example per class; and the four examples name their org, target and location. ONE REPAIR THAT PREDATES THIS WORK: plan_exceptions.expected.yaml carried a comment spliced mid-word by a substitution in an earlier translation pass. It had been on main unread for weeks, which is what a comment nobody executes buys you. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>SIXTH AND LAST. The suite was already green; this is the half a reader meets first. CONVENTION.md: the grammar line, the segment table gaining `target` and losing `site`, the four cases the target slot exists for, the vocabularies with their long forms, the tag table, the seven-digit VMID with its before-and-after, the three exception classes with what each one owes, and a new section 14 - METHOD. THREE THINGS THE DOCUMENT NOW SAYS THAT IT DID NOT: - The two shapes REJECTED for the target slot, and why. A typed pair adds a second separator to a grammar that has one; a conditional rule makes the meaning of segment 3 depend on the value of segment 2, so a name stops being readable left to right. Someone will propose both again, and the reasons are cheaper to read than to rediscover. - That a WORLDWIDE-UNIQUE NAME CANNOT BE IDENTICAL ON TWO PLATFORMS. The v2 table showed one that was, which rested on an unmeasured assumption and is now corrected in place with the reason beside it. - The costs, as measured numbers rather than as a warning to be careful. Five of 3451 standard types cannot hold the v3 grammar, three of them even without a target; an 18-character Proxmox security group keeps no component at all; a GCP project_id needs 33 characters and accepts 30. One portability case breaks, and the README's known limits say so. SECTION 14 IS METHOD, NOT ADVICE, and every rule in it was paid for: a failed search is never an absence; a negative fact about a provider carries the day it was measured; a guard that matters is tested by mutation; the boundary bounds the blast radius while the permission set bounds the capability; ask policy, then condition, then boundary, because reaching for the heaviest instrument first produced the same wrong argument twice in two days. The boundary axis and the organization axis are declared there rather than assumed, with the rule that an exception SWAPS a guard and never removes one. WHAT THIS RELEASE DELIBERATELY DOES NOT DO, written in the README rather than left to be discovered: the kit is still consumed by `?ref=`, not by a vendored copy. Vendoring is the better answer - it works offline, in a worktree, with no credential, and without the bootstrap circularity of a repository that creates the forge hosting its own convention - but it changes the consumer contract, and doing half of it would leave two sources of truth where there is one today. Also: the adoption guides, the consumer template and the module README carry the v3 context (`org`, `target`, `location`) and the v3.0.0 pin, which `make address` enforces in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The location was already in a Scaleway bucket name, but for a reason nobody had measured: the type declares scope: global because the documentation says a bucket name is unique worldwide. Nothing here has put that to the API, and the assessment of 2026-10-27 left it open precisely because an example rested on it. A name is final. So the segment stops resting on that claim: both types whose uniqueness scope is documented rather than measured now ALSO declare location_in_name: true. scaleway_object_bucket the claim is worldwide uniqueness scaleway_registry_namespace the claim is regional uniqueness, which makes the location the ONLY thing telling two regional namespaces apart Two reasons now force one segment, so neither is load-bearing alone. That is deliberate and it is written in the notes: a mutation of either stays silent, and what must never happen is a correction of the scope silently shortening a name that cannot be renamed. Measured, each reason on its own (tools/naming.py, both types): scope=global location_in_name=false -> see-prd-dsd-data-lake-raw-bkt-par scope=project location_in_name=true -> see-prd-dsd-data-lake-raw-bkt-par scope=project location_in_name=false -> see-prd-dsd-data-lake-raw-bkt No generated name changes, and the suite stays green: 70 module tests, 50 policy tests, 11 reference plans, 11 lint mutations. The decision half of open point 1 is closed in the assessment; the measurement itself stays open, with its date and provider version still to record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>