feat!: v3.0.0 - the target axis, a mandatory org, centralised products, declared boundaries #7

Merged
administrator merged 7 commits from feat/v3-target-axis-and-mandatory-org into main 2026-10-28 15:56:01 +00:00
Collaborator

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.

[prefix]org-env-[target-]domain-component[-qualifier]-slug[-location][-nn]

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.

1 registry — the grammar, the vocabularies, products.yaml, forgejo.yaml, the VMID, the three exception classes, organization_axis, tenancy
2 module — the assembly, the target lookup over two registries, the compact style, the product resolution
3 tools — decode with long forms, the three bijection lints, the audit requalification
4 policy — the regexes, the VMID positions, the exception classes
5 tests — 53 cases, 19 portability, 23 audited, 11 plans, 11 lint mutations → green
6 docs — CONVENTION.md, the changelog, the guides

The two decisions worth arguing about

target is ONE position drawing from TWO closed, disjoint registries — entities, and the short codes of products.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.md because someone will propose them again:

  • a typed pair (entity.dataseeds) adds a second separator to a grammar that has one, so every split("-"), every policy rule and naming-check.sh grow a branch;
  • a conditional rule ("when the env is 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 is prd while its target lives in shr.

org is 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

Bug How it hid
the VMID reservation lookup was dead seven digits arrive as a float64 and Go's %d refuses one: sprintf returned "%!d(float64=1.301201e+06)". As the key into vmids.yaml that matches nothing, so every VMID was reported unreserved. Five digits stayed integral and hid it completely
extra tags stopped being checked I pointed bad_tag_values at the canonical values only, excluding var.extra_tags. The guard that refuses a . in a tag value caught it in the same run
check() and the Rego hashed regex refused a name the module produces where max_len is tight the org leaves no component, and org-env-domain-hash has no middle
the VMID policy read the name positionally toks[0] is the org now, not the env — a silent misread, had the corpus not had a case

The costs, 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-char service account loses its slug
per-prd-agt-93397                    an 18-char budget keeps NO component at all
see-prd-dsd-data-base-pr-e5862       a GCP project_id needs 33, accepts 30

⚠️ One portability case BREAKS. prd-agt-runtime-sg was 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 for org-env-domain-component-slug, 3 of them even without a target. Recorded in tests/portability.yaml with that reason, and in the README's known limits.

What was measured, not assumed

  • tofu show -json exposes no lifecycle block at all — no prevent_destroy, no create_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.
  • A plan exposes references under configuration…expressions.<attr>.references; a state exposes depends_on. The collector reads both — verified against real documents, because it returned nothing on the existing fixtures.
  • conftest does expose products.yaml as data.products, so target_codes resolves. Had it not, every standard_regex would have been built from an empty alternation.

The tests that make the new rules non-decorative

tests/lints.py — 11 mutations, 0 silent. lint-registry being 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_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.

Verification

make check green: 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, the products.yaml entries): that belongs to seeds-infra and home-node-infra, each in its own pull request, after this one is tagged.

Open before tagging v3.0.0 — ANSWERED on 2026-10-28

  1. The product codes are accepted — sit, frg, run, pfm, idy, fnd, atl. Final from their first use onward.
  2. per's long form is personal, confirmed. The registry already said so, so nothing changed here — it closes seeds-infra action 52, which renames perso on the consumer side.
  3. The region goes into the bucket name, and 7c1e5d5 makes 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:

type the claim why the location matters
scaleway_object_bucket unique worldwide if it is regional instead, the § 2 example collides on the second region
scaleway_registry_namespace unique within the region two regions MAY carry the same namespace, and nothing but the location tells them apart

Two 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:

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, 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

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. ``` [prefix]org-env-[target-]domain-component[-qualifier]-slug[-location][-nn] ``` ## 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. | | | |---|---| | 1 | `registry` — the grammar, the vocabularies, `products.yaml`, `forgejo.yaml`, the VMID, the three exception classes, `organization_axis`, `tenancy` | | 2 | `module` — the assembly, the target lookup over two registries, the compact style, the product resolution | | 3 | `tools` — `decode` with long forms, the three bijection lints, the audit requalification | | 4 | `policy` — the regexes, the VMID positions, the exception classes | | 5 | `tests` — 53 cases, 19 portability, 23 audited, 11 plans, 11 lint mutations → **green** | | 6 | `docs` — CONVENTION.md, the changelog, the guides | ## The two decisions worth arguing about **`target` is ONE position drawing from TWO closed, disjoint registries** — entities, and the `short` codes of `products.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.md` because someone will propose them again: - a **typed pair** (`entity.dataseeds`) adds a second separator to a grammar that has one, so every `split("-")`, every policy rule and `naming-check.sh` grow a branch; - a **conditional rule** ("when the env is `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 is `prd` while its target lives in `shr`. **`org` is 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 | Bug | How it hid | |---|---| | **the VMID reservation lookup was dead** | seven digits arrive as a float64 and Go's `%d` *refuses* one: `sprintf` returned `"%!d(float64=1.301201e+06)"`. As the key into `vmids.yaml` that matches nothing, so **every VMID was reported unreserved**. Five digits stayed integral and hid it completely | | **extra tags stopped being checked** | I pointed `bad_tag_values` at the canonical values only, excluding `var.extra_tags`. The guard that refuses a `.` in a tag value caught it in the same run | | `check()` and the Rego hashed regex refused a name the module produces | where `max_len` is tight the org leaves no component, and `org-env-domain-hash` has no middle | | the VMID policy read the name positionally | `toks[0]` is the org now, not the env — a silent misread, had the corpus not had a case | ## The costs, 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-char service account loses its slug per-prd-agt-93397 an 18-char budget keeps NO component at all see-prd-dsd-data-base-pr-e5862 a GCP project_id needs 33, accepts 30 ``` ⚠️ **One portability case BREAKS.** `prd-agt-runtime-sg` was 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 for `org-env-domain-component-slug`, **3** of them even without a target. Recorded in `tests/portability.yaml` with that reason, and in the README's known limits. ## What was measured, not assumed - `tofu show -json` exposes **no lifecycle block at all** — no `prevent_destroy`, no `create_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. - A **plan** exposes references under `configuration…expressions.<attr>.references`; a **state** exposes `depends_on`. The collector reads both — verified against real documents, because it returned **nothing** on the existing fixtures. - `conftest` does expose `products.yaml` as `data.products`, so `target_codes` resolves. Had it not, every `standard_regex` would have been built from an empty alternation. ## The tests that make the new rules non-decorative **`tests/lints.py` — 11 mutations, 0 silent.** `lint-registry` being 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_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. ## Verification `make check` green: 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, the `products.yaml` entries): that belongs to `seeds-infra` and `home-node-infra`, each in its own pull request, after this one is tagged. ## Open before tagging v3.0.0 — ANSWERED on 2026-10-28 1. **The product codes are accepted** — `sit`, `frg`, `run`, `pfm`, `idy`, `fnd`, `atl`. Final from their first use onward. 2. **`per`'s long form is `personal`**, confirmed. The registry already said so, so nothing changed here — it closes `seeds-infra` action 52, which renames `perso` on the consumer side. 3. **The region goes into the bucket name**, and `7c1e5d5` makes 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`: | type | the claim | why the location matters | | --- | --- | --- | | `scaleway_object_bucket` | unique worldwide | if it is regional instead, the § 2 example collides on the second region | | `scaleway_registry_namespace` | unique within the region | two regions MAY carry the same namespace, and nothing but the location tells them apart | Two 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: ``` 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, 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](https://claude.com/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>
docs!: CONVENTION.md, the changelog and the guides describe v3
Some checks failed
ci / check (pull_request) Has been cancelled
50d1a70370
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>
administrator deleted branch feat/v3-target-axis-and-mandatory-org 2026-10-28 15:56:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
shared/infra-conventions!7
No description provided.