diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md
index f2cb9c432b..fcac11c85e 100644
--- a/docs/qa/platform-checklist/README.md
+++ b/docs/qa/platform-checklist/README.md
@@ -142,7 +142,9 @@ copy its shape rather than inventing a second one.
"app": "showcase",
"requires": ["…"], // item-specific needs stay here
"provisioning": {
- "use": "qa-scratch-authz", // must match a key in the AREA's fixtures block
+ "use": "qa-scratch-authz", // a key in THIS area's fixtures block — or
+ // ":" for one another area owns
+ // (see "the qualified `use`" below)
"why": "which of THIS item's clauses the recipe unblocks, and what they'd score without it"
},
"knownGaps": ["CLOSED by the qa-scratch-authz recipe (#7670): … ; fall back to only if …"]
@@ -165,27 +167,46 @@ Why this shape:
*CLOSED-by-recipe*, naming any pinned fallback and asking the run to record **which**
of the two its verdict rests on. Deleting the gap loses the reason the recipe exists.
-The validator **resolves `provisioning.use` against its own area's `fixtures` keys**: a
-`use` naming a key that area does not define fails `check:platform-checklist`, naming the
-item, the key that resolved to nothing, and the recipes the area does offer. This is
-option C on #7716's open question — deferred at #7720 while the recipe shape lived in a
-single area, landed at #10593 on its own stated condition, once the shape had spread to
-three areas and six references.
-
-Two things the resolve deliberately does **not** do:
-
-- **It does not flag a recipe no item references.** Cross-area reuse has no spelling yet
- (below), so a recipe whose only consumer lives in another area is referenced from that
- item's `knownGaps` prose — invisible to the check. Redding the unreferenced direction
- would answer the cross-area question by accident, in the direction of "recipes are
- area-local", and that is a convention decision rather than a mechanical one.
-- **It does not reach across areas.** Resolution is area-scoped because the mechanism is:
- `use` names a key in the item's *own* file. A `use` pointing at another area's recipe
- key is therefore a dangling pointer and fails — there is no qualified spelling
- (`search:qa-contributor-bound-member` or similar) and no shared recipe file. A
- cross-area consumer still cites the recipe **by name in `knownGaps`** and does not fork
- a second copy; `records-forms.crud-roundtrip` clause 7 is the worked instance. Giving
- that pointer a spelling the tooling can see is the open half of #10593.
+#### Referencing a recipe another area owns — the qualified `use`
+
+A recipe is proved by one area and sometimes needed by a second. `use` therefore has
+**two spellings**, and the validator resolves both:
+
+```jsonc
+"use": "qa-scratch-authz" // a recipe of THIS item's own area
+"use": "search:qa-contributor-bound-member" // ":" — one another area owns
+```
+
+The area half is the **filename stem** (`search` → `areas/search.json`). Prefer the bare
+key when the recipe is your area's; reach for the qualifier when it is not — and **never
+fork a second copy** of a recipe into your area, which is the drift this spelling exists
+to prevent. `records-forms.crud-roundtrip` clause 7 is the worked instance: it needs
+`qa-contributor-bound-member`, which `areas/search.json` owns.
+
+This is option A on #10593's second gap, ruled by the maintainer on 2026-08-22 over a
+shared `areas/_fixtures.json` (which would move a recipe away from the area that proved
+it, and make a hot shared file out of one the area sharding deliberately keeps cold) and
+over "recipes stay area-local, duplicate with a back-reference" (which accepts the drift).
+Every reference already written keeps working: the qualifier is an addition, not a
+migration.
+
+The validator **resolves `provisioning.use` to a real recipe** — its own area's key, or
+the named area's — and a reference that resolves to nothing fails `check:platform-checklist`,
+naming the item and distinguishing the three ways it can fail: the named area does not
+exist, the area exists but does not define that recipe, or the reference is malformed
+(`a:b:c`, `search:`, a padded half). A bare key that some *other* area defines is told
+the exact qualified spelling to write. This is option C on #7716's open question,
+deferred at #7720 while the recipe shape lived in a single area and landed at #10593
+once it had spread to three areas and six references.
+
+One thing the resolve deliberately does **not** do:
+
+- **It does not flag a recipe no item references.** It was left out while cross-area
+ reuse had no spelling, because redding it would have answered that question by
+ accident in the direction of "recipes are area-local". With the qualified spelling
+ ruled, that reason has expired and an unreferenced recipe is unambiguously dead text —
+ but turning the direction on is its own change with its own blast radius, tracked at
+ #11506 rather than folded in here.
⚠️ Remember the cadence: this gate is **not** CI-wired (above), so it catches a typo'd
`use` at the next manual run, not on the PR that introduced it. Copy the key, don't retype it.
diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json
index 227ff0069b..071e720e17 100644
--- a/docs/qa/platform-checklist/areas/records-forms.json
+++ b/docs/qa/platform-checklist/areas/records-forms.json
@@ -8,7 +8,7 @@
"title": "Create → read → update → delete a record through the console UI",
"since": "v15",
"status": "active",
- "revision": 6,
+ "revision": 7,
"priority": "P0",
"surface": "browser",
"personas": [
@@ -19,8 +19,12 @@
"requires": [
"showcase_account — writable standard object (sharingModel public_read_write), required name + status, format validations tax_id_format / billing_email_format (examples/app-showcase/src/data/objects/account.object.ts)"
],
+ "provisioning": {
+ "use": "search:qa-contributor-bound-member",
+ "why": "supplies the persona clause 7 (clone is RLS-gated) rests on and that no other clause here needs — a reader for whom a SOURCE row is invisible. showcase_account, the object the rest of this item drives, is public_read_write, so no persona can fail to read a row on it and the clause is unscoreable there; clause 7 is driven on showcase_invoice against INV-1003 (linus@example.com's) instead. Without the recipe the clause scores blocked(fixture) — which is what the 17.1.0 sweep recorded after reaching for the seeded demo personas and cloning showcase_account as Mei, who reads it fine (#10236)."
+ },
"knownGaps": [
- "Clause 7 (clone is RLS-gated) cannot be scored on showcase_account, the object every other clause here drives: it is public_read_write, so there is no persona for whom a source row is invisible. It needs showcase_invoice plus a contributor-bound member — the recipe qa-contributor-bound-member in areas/search.json. ⚠️ The recipe mechanism is AREA-SCOPED (provisioning.use must name a key in this area's own fixtures block, README.md) and the validator now ENFORCES that scoping (#10593): a same-area `use` RESOLVES, and a `use` naming another area's recipe key FAILS check:platform-checklist as a dangling pointer, naming the item and the key that resolved to nothing. ⛔ So this reference cannot be spelled as `use` today — no cross-area spelling exists that the tooling accepts, and giving that pointer one is the open half of #10593 — and this item cross-references the recipe by name rather than opting in. Replay it from there; do not fork a second copy into this area."
+ "Clause 7 (clone is RLS-gated) cannot be scored on showcase_account, the object every other clause here drives: it is public_read_write, so there is no persona for whom a source row is invisible. It needs showcase_invoice plus a contributor-bound member — the recipe qa-contributor-bound-member, which areas/search.json owns. That reference now lives in fixtures.provisioning as the AREA-QUALIFIED `search:qa-contributor-bound-member`, and check:platform-checklist RESOLVES it (#10593 gap 2, maintainer ruling 2026-08-22 — option A): both spellings are valid, an own-area key and `:` for one another area owns. So this is a real opt-in rather than a prose pointer — rename or move the recipe and this item fails the gate by name, instead of leaving a sentence aimed at nothing. ⛔ Replay it from areas/search.json; do not fork a second copy into this area."
]
},
"steps": [
@@ -81,7 +85,7 @@
{
"clause": "clone is RLS-gated: the source is fetched in the caller's context (engine.findOne with context), so cloning a record the caller cannot SEE is refused with 404 RECORD_NOT_FOUND — never a silent duplicate of an invisible row",
"oracle": "api",
- "verify": "⚠️ NOT on showcase_account — it is public_read_write, so no persona can fail to read a source row there and the clause is unscoreable on the object the rest of this item drives. Drive it on showcase_invoice with a contributor-bound member (recipe qa-contributor-bound-member in areas/search.json; the area-scoped recipe mechanism means this item cannot `use` it — replay it from there): as that member, POST the clone route against INV-1003 (linus@example.com's, invisible to them) → 404 RECORD_NOT_FOUND and no new row lands (recordNotFoundError from the null findOne). ⛔ Distinguish the two refusals before scoring: 404 RECORD_NOT_FOUND means the caller cannot SEE the source (this clause); 403 PERMISSION_DENIED means they can see it but hold no create grant — a DIFFERENT gate, and the one a persona with read-but-not-create hits. The 17.1.0 sweep hit exactly that confusion by cloning showcase_account as Mei, who reads it fine (#10236). Separately, an enable.clone:false object refuses with 403 CLONE_DISABLED",
+ "verify": "⚠️ NOT on showcase_account — it is public_read_write, so no persona can fail to read a source row there and the clause is unscoreable on the object the rest of this item drives. Drive it on showcase_invoice with a contributor-bound member (recipe qa-contributor-bound-member, owned by areas/search.json and opted into by this item as `search:qa-contributor-bound-member` — see fixtures.provisioning; replay it from there rather than forking a copy): as that member, POST the clone route against INV-1003 (linus@example.com's, invisible to them) → 404 RECORD_NOT_FOUND and no new row lands (recordNotFoundError from the null findOne). ⛔ Distinguish the two refusals before scoring: 404 RECORD_NOT_FOUND means the caller cannot SEE the source (this clause); 403 PERMISSION_DENIED means they can see it but hold no create grant — a DIFFERENT gate, and the one a persona with read-but-not-create hits. The 17.1.0 sweep hit exactly that confusion by cloning showcase_account as Mei, who reads it fine (#10236). Separately, an enable.clone:false object refuses with 403 CLONE_DISABLED",
"evidence": "the 404 RLS refusal with its code shown (not merely its status), the premise read proving the source row was invisible to that persona first, and a post-attempt count showing no new row"
}
],
@@ -135,6 +139,12 @@
"date": "2026-08-21",
"change": "corrected a knownGap that had gone from stale FACT to stale PERMISSION. Clause 7's cross-area note said `provisioning.use` was 'unresolved by the validator either way' — true when written (deferred at #7716 / tracked #7720), false since the area-scoped resolve landed at #10593: a same-area `use` now resolves and a cross-area one FAILS check:platform-checklist, naming the item. The prose therefore invited exactly the edit the gate rejects. The note now states what is enforced today; the cross-area SPELLING is still undecided (the open half of #10593) and is deliberately NOT predicted here, so this correction cannot itself become the next stale permission. Item substance, steps, personas, fixtures and all acceptance clauses are unchanged — no run verdict is invalidated by this revision",
"ref": "#10809"
+ },
+ {
+ "revision": 7,
+ "date": "2026-08-24",
+ "change": "clause 7's cross-area recipe pointer became a real reference. The persona it needs — qa-contributor-bound-member — is owned by areas/search.json, and until now no spelling existed that the validator accepted, so the pointer lived in knownGaps prose and clause 7's verify text: readable by a runner, invisible to check:platform-checklist, and free to rot if the recipe were ever renamed or moved. The maintainer ruled option A on 2026-08-22 (#10593 gap 2, area-qualified references), so this item now carries fixtures.provisioning.use = `search:qa-contributor-bound-member` and the gate resolves it. What a RUN does is unchanged in every particular: the same recipe, replayed from the same file, unblocking the same single clause on the same object and row — clause 7 was already instructed to drive showcase_invoice with this persona, and no step, clause, oracle, persona or acceptance text changed. The revision is bumped because the item's fixtures did, and because a run record pinning revision 6 was scored against an item whose recipe reference the tooling could not check",
+ "ref": "#10593"
}
]
},
diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json
index ef7f011ae8..9ea0c1f79b 100644
--- a/docs/qa/platform-checklist/areas/search.json
+++ b/docs/qa/platform-checklist/areas/search.json
@@ -49,7 +49,7 @@
],
"teardown": "delete the sys_user_position row and the invoice created in step 5, or simply discard the isolated file DB — the cheaper path an isolated boot makes free. The signed-up principal is not otherwise cleaned up; use a unique per-run email so a leftover account never collides.",
"knownGaps": [
- "⚠️ The recipe mechanism is AREA-SCOPED: `provisioning.use` must name a key in the item's OWN area fixtures block (README.md), and the validator now RESOLVES it (#10593): a same-area `use` resolves, and one naming another area's recipe key FAILS check:platform-checklist as a dangling pointer, naming the item and the key that resolved to nothing. records-forms.crud-roundtrip clause 7 needs this same persona and lives in another area file, so ⛔ it CANNOT `use` this recipe — no cross-area spelling exists that the tooling accepts today, and giving that pointer one is the open half of #10593 — and it cross-references this recipe by name in its knownGaps instead. Replay it from here; do not fork a second copy.",
+ "⚠️ This recipe has consumers in TWO areas, and the validator resolves both (#10593, maintainer ruling 2026-08-22 — option A, area-qualified references). `provisioning.use` accepts either spelling: a bare key naming a recipe of the item's own area (search.rls-both-personas uses `qa-contributor-bound-member`), or `:` naming one another area owns — records-forms.crud-roundtrip clause 7 needs this same persona and opts in from records-forms.json as `search:qa-contributor-bound-member`. Both FAIL check:platform-checklist loudly if this recipe is renamed, moved, or removed, naming the item and what resolved to nothing; the cross-area pointer used to be knownGaps prose the tooling could not see, which is the drift the qualified spelling closes. ⛔ Replay it from here; do not fork a second copy into another area — this block is the one home, and that is what the qualifier buys.",
"The persona is a subset reader of showcase_invoice ONLY. showcase_account is public_read_write and showcase_contributor grants plain allowRead on it with no RLS, so a contributor still reads every account row — an item needing an invisible SOURCE row must drive showcase_invoice, not showcase_account.",
"showcase_private_note is an EMPTY table for everyone and showcase_account reads identically (15 rows) for admin, Mei and Ada (#10236) — neither is usable as a both-sides RLS control. Recorded so the next runner does not reach for them."
]
@@ -208,7 +208,7 @@
"title": "Search honors RLS both ways: a restricted member gets no hits — and no total leakage — from rows they cannot see; the entitled persona finds the same rows",
"since": "v15",
"status": "active",
- "revision": 5,
+ "revision": 6,
"priority": "P1",
"surface": "api",
"personas": ["seeded admin (sees all invoices)", "signed-up member bound to the contributor position via the qa-contributor-bound-member recipe (invoice_own_rows RLS: owner == current_user.email) — ⛔ NOT one of the seeded demo personas: Mei holds no position (PERMISSION_DENIED, object gate) and Ada holds auditor with viewAllRecords (all 12). See the recipe."],
@@ -286,6 +286,12 @@
"date": "2026-08-21",
"change": "pins the knownGaps correction made on the area recipe this item `use`s. qa-contributor-bound-member's first knownGap claimed the validator 'does not resolve it either way', which the area-scoped resolve at #10593 made false — a same-area `use` resolves, a cross-area one fails. Area-level recipes carry no `revision`/`history` of their own (the lifecycle check in scripts/check-platform-checklist.mjs is item-scoped, and none of the four recipes in this checklist has either field), so the revision of the item that `use`s the recipe is the ONLY pin a run record can hold that text against — which is why this item is bumped although none of its own fields changed. Steps, clauses, personas, fixtures and provisioning are all unchanged and no run verdict is invalidated",
"ref": "#10809"
+ },
+ {
+ "revision": 6,
+ "date": "2026-08-24",
+ "change": "pins the second knownGaps correction on the area recipe this item `use`s, for the same reason revision 5 pinned the first: area-level recipes carry no revision/history of their own, so the revision of a consuming item is the only thing a run record can hold that text against (#10885 tracks giving recipes their own, and is deliberately not pre-empted here). qa-contributor-bound-member's first knownGap said records-forms.crud-roundtrip ⛔ CANNOT `use` this recipe because no cross-area spelling existed — true when written, false since the maintainer ruled option A on 2026-08-22 (#10593 gap 2): `provisioning.use` now also accepts `:`, and that item opts in as `search:qa-contributor-bound-member`. The note now describes a recipe with consumers in two areas, both machine-resolved. THIS item's own reference is untouched and still the bare same-area `qa-contributor-bound-member` — every existing spelling stays valid, which is part of the ruling. Steps, clauses, personas, fixtures and provisioning are all unchanged and no run verdict is invalidated",
+ "ref": "#10593"
}
]
},
diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs
index 79fff84432..7db192b450 100644
--- a/scripts/check-platform-checklist.mjs
+++ b/scripts/check-platform-checklist.mjs
@@ -37,8 +37,9 @@
// vocabulary is READ from that table, never copied into this file, and a
// table this script cannot parse is a refusal rather than an empty
// allow-list (see the trap-vocabulary block below for why that matters).
-// - every `fixtures.provisioning.use` resolves to a recipe key in its OWN
-// area's area-level `fixtures` block — an item that reads as provisioned
+// - every `fixtures.provisioning.use` resolves to a real recipe — either a
+// key of its own area's area-level `fixtures` block, or `:`
+// naming one another area owns — because an item that reads as provisioned
// and is not costs the run the clauses the recipe was meant to unblock,
// mid-run and on a live boot (see the provisioning block below).
//
@@ -224,7 +225,7 @@ function trapProblems(item, vocabulary) {
return out;
}
-// ── Area-scoped provisioning recipes (`fixtures.provisioning.use`) ──────────
+// ── Provisioning recipes (`fixtures.provisioning.use`) ─────────────────────
// An area may write ONE provisioning recipe at the area level and have many
// items opt into it by key (README "Area-level `fixtures` — one named
// provisioning recipe, many items"). Both halves have to be real: a recipe
@@ -238,19 +239,26 @@ function trapProblems(item, vocabulary) {
// tracked at #7720), on the stated condition that it be revisited if the shape
// spread. It has: three area files, six references (#10593).
//
-// Scope, deliberately narrow in two directions:
+// TWO SPELLINGS, one lookup (maintainer ruling 2026-08-22, #10593 gap 2 —
+// option A, area-qualified references):
//
-// 1. SAME-AREA resolution only, because that is the mechanism the README
-// documents. Cross-area reuse has no spelling at all today (the second,
-// undecided gap on #10593), so a `use` pointing at another area's recipe
-// key IS a dangling pointer here — and the message says so, instead of
-// letting a reference that resolves nowhere read as if it worked.
-// 2. The reverse direction — a recipe no item references — is NOT checked,
-// unlike the bidirectional trap vocabulary above. With cross-area reuse
-// unspelled, a recipe whose only consumer sits in another area is cited
-// from that item's `knownGaps` prose; redding it here would settle gap 2
-// by accident, in the direction of "recipes are area-local", which is a
-// convention decision and not this check's to make.
+// "qa-scratch-authz" → a recipe of the item's OWN area
+// "search:qa-contributor-bound-member" → ":", any area
+//
+// The qualified form exists because a recipe is proved by one area and needed
+// by another, and until it existed that pointer could only be prose. Prose is
+// the one form that cannot drift-check: rename the recipe and `search.json`
+// moves while the sentence in `records-forms.json` does not. The unqualified
+// form stays exactly as valid as it was — every reference already written
+// resolves unchanged; the qualifier is an addition, not a migration.
+//
+// Deliberately still NOT checked: the reverse direction, a recipe no item
+// references. It was left out while cross-area reuse was unspelled, because
+// redding it would have settled that convention by accident in the direction
+// of "recipes are area-local". That reason has now expired — with a qualified
+// spelling available, an unreferenced recipe is unambiguously dead text — but
+// turning it on is a separate change with its own blast radius, tracked rather
+// than smuggled in here (#11506).
//
// `$`-prefixed keys are annotations, not recipes — every area `fixtures` block
// opens with a `$comment` stating the block's purpose and replay rule — so
@@ -263,28 +271,91 @@ function areaRecipeKeys(doc) {
return Object.keys(f).filter((k) => !k.startsWith('$'));
}
-/** Problems with one item's `fixtures.provisioning`, as message strings. Pure; battery-tested below. */
-function provisioningProblems(item, recipeKeys) {
+const USE_SEP = ':';
+
+/**
+ * Split a `use` into the area it addresses and the recipe key it names.
+ * `{ area: null }` is the unqualified spelling (resolve against the item's own
+ * area). `{ malformed }` is a shape that is neither — reported as itself
+ * rather than resolved to nothing, because "no recipe called `search:`" would
+ * send the author hunting for a recipe when the defect is the reference.
+ */
+function parseUse(use) {
+ if (!use.includes(USE_SEP)) return { area: null, recipe: use };
+ const parts = use.split(USE_SEP);
+ if (parts.length > 2) {
+ return { malformed: `it carries ${parts.length - 1} "${USE_SEP}" separators — a qualified reference names exactly one area and one recipe` };
+ }
+ const [area, recipe] = parts;
+ if (!area) return { malformed: `its area half is empty — write "${USE_SEP}", or drop the "${USE_SEP}" to name a recipe of this item's own area` };
+ if (!recipe) return { malformed: `its recipe half is empty — write "${USE_SEP}"` };
+ if (area !== area.trim() || recipe !== recipe.trim()) {
+ return { malformed: 'it has whitespace around a half — a padded name is a different string to everyone who greps for it' };
+ }
+ return { area, recipe, qualified: true };
+}
+
+/**
+ * Problems with one item's `fixtures.provisioning`, as message strings.
+ *
+ * `ownArea` is the item's area (the filename stem); `recipesByArea` maps every
+ * area to its recipe keys, so a qualified `use` can be resolved against the
+ * area it names. Pure; battery-tested below.
+ */
+function provisioningProblems(item, ownArea, recipesByArea) {
const out = [];
const p = item?.fixtures?.provisioning;
if (p === undefined) return out; // optional field: 6 of 205 items opt into a recipe
if (typeof p !== 'object' || p === null || Array.isArray(p) || typeof p.use !== 'string' || !p.use.trim()) {
- out.push('"fixtures.provisioning" must carry a non-empty string "use" naming a recipe in this area\'s area-level "fixtures" block — a provisioning block that opts into nothing reads as provisioned and is not');
+ out.push(`"fixtures.provisioning" must carry a non-empty string "use" naming a recipe — either a key of this area's area-level "fixtures" block, or "${USE_SEP}" for one another area owns — a provisioning block that opts into nothing reads as provisioned and is not`);
return out;
}
- const recipes = new Set(recipeKeys);
- if (recipes.has(p.use)) return out;
+
+ const parsed = parseUse(p.use);
+ if (parsed.malformed) {
+ out.push(
+ `"fixtures.provisioning.use" is ${JSON.stringify(p.use)}, which is not a usable reference: ${parsed.malformed}.` +
+ ` Both spellings are accepted: \`qa-scratch-authz\` (a recipe of this area) or \`search${USE_SEP}qa-contributor-bound-member\` (one another area owns).`,
+ );
+ return out;
+ }
+
+ // Which area's block answers this reference — the named one, or this item's own.
+ const targetArea = parsed.area ?? ownArea;
+ const recipes = new Set(recipesByArea.get(targetArea) ?? []);
+ if (recipes.has(parsed.recipe)) return out;
+
+ if (parsed.qualified && !recipesByArea.has(targetArea)) {
+ const areas = [...recipesByArea.keys()];
+ const withRecipes = areas.filter((a) => (recipesByArea.get(a) ?? []).length > 0);
+ out.push(
+ `"fixtures.provisioning.use" is "${p.use}", whose area half names "${targetArea}" — there is no such area file` +
+ `${didYouMean(targetArea, areas)} — the areas that define recipes today are ${withRecipes.map((a) => `\`${a}\``).join(', ')}.`,
+ );
+ return out;
+ }
+
+ // The recipe key is unknown wherever we were told to look. When some OTHER
+ // area does define it, say so with the exact spelling to write: an author
+ // who copied an unqualified key across areas is one qualifier away from a
+ // working reference, and that is the drift this two-level lookup exists for.
+ const elsewhere = [...recipesByArea].filter(([a, keys]) => a !== targetArea && keys.includes(parsed.recipe)).map(([a]) => a);
+ const hint = elsewhere.length
+ ? ` — ${elsewhere.map((a) => `\`${a}\``).join(', ')} define${elsewhere.length === 1 ? 's' : ''} it: write \`${elsewhere[0]}${USE_SEP}${parsed.recipe}\` to opt into it from here, and do NOT fork a second copy into this area.`
+ : '';
+
if (recipes.size === 0) {
out.push(
- `"fixtures.provisioning.use" names "${p.use}" but this area file has no area-level "fixtures" block to resolve it against` +
- ' — write the recipe as a sibling of "area"/"title"/"items" (README "Area-level `fixtures`"), or drop the reference.',
+ `"fixtures.provisioning.use" names "${parsed.recipe}"` +
+ (parsed.qualified ? ` in area "${targetArea}", which has no area-level "fixtures" block to resolve it against` : ' but this area file has no area-level "fixtures" block to resolve it against') +
+ (hint || ' — write the recipe as a sibling of "area"/"title"/"items" (README "Area-level `fixtures`"), or drop the reference.'),
);
return out;
}
+
out.push(
- `"fixtures.provisioning.use" names "${p.use}", which is not a recipe in this area's area-level "fixtures" block${didYouMean(p.use, recipes)}` +
- ` — this area offers ${[...recipes].map((k) => `\`${k}\``).join(', ')}. References are AREA-SCOPED: a recipe another area owns cannot be opted into by key` +
- ' (cross-area reuse has no spelling yet, #10593) — cite that recipe by name in this item\'s "knownGaps" instead, and do not fork a second copy.',
+ `"fixtures.provisioning.use" names "${parsed.recipe}", which is not a recipe in ${parsed.qualified ? `area "${targetArea}"'s` : "this area's"} area-level "fixtures" block${didYouMean(parsed.recipe, recipes)}` +
+ ` — ${parsed.qualified ? `\`${targetArea}\`` : 'this area'} offers ${[...recipes].map((k) => `\`${k}\``).join(', ')}${hint || '.'}`,
);
return out;
}
@@ -366,6 +437,13 @@ function selfTestTrapVocabulary() {
* `qa-media-constraints`) and a cross-area `use` each validated clean, exit 0,
* printing the same OK line as an untouched tree. A check that quietly stopped
* firing would restore exactly that green. Zero I/O — every subject literal.
+ *
+ * The qualified half (Q…) carries the same burden twice over, because a
+ * two-level lookup has a failure mode the one-level one did not: resolving too
+ * MUCH. A widening bug that let any recipe answer any reference would pass
+ * every "it resolves" assertion, so each of the three failure shapes — real
+ * area / missing key, missing area, malformed reference — is pinned firing,
+ * and pinned saying which of the three it is.
*/
function selfTestProvisioningUse() {
const failures = [];
@@ -375,31 +453,86 @@ function selfTestProvisioningUse() {
if (!ok) failures.push(what);
};
+ // A miniature of the real ledger: the area that owns the recipes under test,
+ // a second area that owns the one every cross-area reference wants, and a
+ // third that defines no recipes at all (the shape 12 of 15 area files have).
const keys = areaRecipeKeys({ fixtures: { $comment: 'what this block is, and the replay rule', 'qa-scratch-authz': {}, 'qa-media-constraints': {} } });
+ const byArea = new Map([
+ ['attachments-storage', keys],
+ ['search', areaRecipeKeys({ fixtures: { $comment: 'x', 'qa-contributor-bound-member': {} } })],
+ ['records-forms', areaRecipeKeys({ area: 'records-forms', items: [] })],
+ ]);
+ const HERE = 'attachments-storage';
const item = (use) => ({ fixtures: { app: 'showcase', provisioning: { use, why: 'which clauses it unblocks' } } });
+ const check = (use, from = HERE) => provisioningProblems(item(use), from, byArea);
- t('U1 a `use` naming a recipe of this area passes', provisioningProblems(item('qa-scratch-authz'), keys).length === 0);
- t('U2 an item whose fixtures carry no provisioning is fine (optional field)', provisioningProblems({ fixtures: { app: 'showcase' } }, keys).length === 0);
- t('U3 an item with no fixtures block at all is fine', provisioningProblems({}, keys).length === 0);
+ // ── The unqualified spelling — every one of these predates the qualifier and
+ // must behave identically after it (the ruling's "existing spellings stay valid").
+ t('U1 a `use` naming a recipe of this area passes', check('qa-scratch-authz').length === 0);
+ t('U2 an item whose fixtures carry no provisioning is fine (optional field)', provisioningProblems({ fixtures: { app: 'showcase' } }, HERE, byArea).length === 0);
+ t('U3 an item with no fixtures block at all is fine', provisioningProblems({}, HERE, byArea).length === 0);
- const dangling = provisioningProblems(item('qa-recipe-nobody-wrote'), keys);
+ const dangling = check('qa-recipe-nobody-wrote');
t('U4 a `use` no recipe answers to is flagged', dangling.length === 1 && dangling[0].includes('qa-recipe-nobody-wrote'));
- const typo = provisioningProblems(item('qa-media-constraint'), keys);
+ const typo = check('qa-media-constraint');
t('U5 a TYPO of a real recipe is flagged — the drift shape review is worst at', typo.length === 1);
t('U6 the typo message names the recipe that was meant', typo.length === 1 && typo[0].includes('did you mean `qa-media-constraints`'));
t('U7 the message lists the recipes this area does offer', typo.length === 1 && typo[0].includes('`qa-scratch-authz`'));
- t('U8 a recipe key ANOTHER area owns does not resolve here (references are area-scoped)', provisioningProblems(item('qa-contributor-bound-member'), keys).length === 1);
- t('U9 `$comment` is an annotation, not a recipe', !keys.includes('$comment') && provisioningProblems(item('$comment'), keys).length === 1);
- t('U10 a whitespace-padded spelling does not resolve', provisioningProblems(item('qa-scratch-authz '), keys).length === 1);
- t('U11 a provisioning block with no "use" is flagged', provisioningProblems({ fixtures: { provisioning: { why: 'because' } } }, keys).length === 1);
- t('U12 a non-string "use" is flagged', provisioningProblems({ fixtures: { provisioning: { use: 42 } } }, keys).length === 1);
+ t('U8 an UNQUALIFIED key another area owns still does not resolve — the qualifier is required, not optional', check('qa-contributor-bound-member').length === 1);
+ t('U9 `$comment` is an annotation, not a recipe', !keys.includes('$comment') && check('$comment').length === 1);
+ t('U10 a whitespace-padded spelling does not resolve', check('qa-scratch-authz ').length === 1);
+ t('U11 a provisioning block with no "use" is flagged', provisioningProblems({ fixtures: { provisioning: { why: 'because' } } }, HERE, byArea).length === 1);
+ t('U12 a non-string "use" is flagged', provisioningProblems({ fixtures: { provisioning: { use: 42 } } }, HERE, byArea).length === 1);
- const noBlock = provisioningProblems(item('qa-scratch-authz'), areaRecipeKeys({ area: 'records-forms', items: [] }));
+ const noBlock = check('qa-scratch-authz', 'records-forms');
t('U13 an area with NO fixtures block says so, rather than offering an empty list', noBlock.length === 1 && noBlock[0].includes('no area-level "fixtures" block'));
t('U14 a fixtures block holding only annotations exposes zero recipes', areaRecipeKeys({ fixtures: { $comment: 'x' } }).length === 0);
+ // ── The qualified spelling `:` (#10593 gap 2, ruled 2026-08-22).
+ // This half is the whole point of the widening, so every limb is observed
+ // both resolving and failing — a two-level lookup that only ever passes has
+ // exactly the same signature as one that resolves everything it is handed.
+ t('Q1 a qualified `use` resolves against the area it names — the ruled spelling, from the area that needs it', check('search:qa-contributor-bound-member', 'records-forms').length === 0);
+ t('Q2 the same reference resolves from ANY area, not just the one worked example', check('search:qa-contributor-bound-member').length === 0);
+ t('Q3 a qualified `use` may name the item\'s OWN area — one lookup, no special case', check('attachments-storage:qa-scratch-authz').length === 0);
+
+ const badKey = check('search:qa-recipe-nobody-wrote', 'records-forms');
+ t('Q4 FAILURE SHAPE 1 — a real area, a key it does not define, is flagged', badKey.length === 1);
+ t('Q5 that message names the area whose block was searched, not the item\'s own', badKey.length === 1 && badKey[0].includes('area "search"'));
+ t('Q6 and lists what that area does offer', badKey.length === 1 && badKey[0].includes('`qa-contributor-bound-member`'));
+
+ const keyTypo = check('search:qa-contributor-bound-membr', 'records-forms');
+ t('Q7 a typo in the RECIPE half gets a did-you-mean from the named area\'s keys', keyTypo.length === 1 && keyTypo[0].includes('did you mean `qa-contributor-bound-member`'));
+
+ const badArea = check('serch:qa-contributor-bound-member', 'records-forms');
+ t('Q8 FAILURE SHAPE 2 — an area half naming no area file is flagged', badArea.length === 1);
+ t('Q9 that message says the AREA is missing, not that the recipe is', badArea.length === 1 && badArea[0].includes('no such area file') && !badArea[0].includes('is not a recipe'));
+ t('Q10 and offers the area that was meant', badArea.length === 1 && badArea[0].includes('did you mean `search`'));
+ t('Q11 it also names the areas that do define recipes', badArea.length === 1 && badArea[0].includes('`attachments-storage`'));
+
+ const emptyArea = check('records-forms:qa-anything', HERE);
+ t('Q12 a qualified `use` into a real area with NO recipe block says so', emptyArea.length === 1 && emptyArea[0].includes('no area-level "fixtures" block'));
+ t('Q13 a qualified `use` cannot reach an annotation key either', check('search:$comment', 'records-forms').length === 1);
+
+ // FAILURE SHAPE 3 — the reference is malformed, and says so rather than
+ // sending the author to hunt for a recipe named `search:` or ``.
+ const malformed = (use) => check(use, 'records-forms');
+ t('Q14 two separators is a malformed reference, not a missing recipe', malformed('a:b:c').length === 1 && malformed('a:b:c')[0].includes('not a usable reference'));
+ t('Q15 an empty area half is malformed', malformed(':qa-contributor-bound-member').length === 1 && malformed(':qa-contributor-bound-member')[0].includes('area half is empty'));
+ t('Q16 an empty recipe half is malformed', malformed('search:').length === 1 && malformed('search:')[0].includes('recipe half is empty'));
+ t('Q17 whitespace around a half is malformed — a padded name is a different string', malformed('search: qa-contributor-bound-member').length === 1 && malformed('search: qa-contributor-bound-member')[0].includes('whitespace'));
+
+ // The upgrade that makes the drift self-correcting: an author who copied an
+ // unqualified key across areas is told the exact spelling that works. Before
+ // the ruling this same message said no cross-area spelling existed.
+ const hinted = check('qa-contributor-bound-member');
+ t('Q18 an unqualified key another area owns is told the qualified spelling to write', hinted.length === 1 && hinted[0].includes('`search:qa-contributor-bound-member`'));
+ t('Q19 and is told not to fork a second copy', hinted.length === 1 && hinted[0].includes('do NOT fork a second copy'));
+ const hintedEmpty = check('qa-contributor-bound-member', 'records-forms');
+ t('Q20 the same hint reaches an area that has no recipe block of its own', hintedEmpty.length === 1 && hintedEmpty[0].includes('`search:qa-contributor-bound-member`'));
+
return { checked, failures };
}
@@ -410,7 +543,7 @@ if (process.argv.slice(2).includes('--self-test')) {
if (failures.length === 0) {
console.log(
`✓ check-platform-checklist --self-test: ${trap.checked + prov.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one;` +
- ' `fixtures.provisioning.use` resolves against its own area and fires on a dangling one.',
+ ' `fixtures.provisioning.use` resolves both spellings (own-area key and `:`) and fires on all three dangling shapes.',
);
process.exit(0);
}
@@ -464,18 +597,32 @@ if (files.length === 0) {
const allIds = new Map(); // id -> file
const allItems = [];
-let recipeTotal = 0; // area-level provisioning recipes, across all areas
let recipeRefs = 0; // item references that resolved to one
-
+let qualifiedRefs = 0; // ...of which named another area explicitly
+
+// Pass 1 — parse every area file. A qualified `use` resolves against ANOTHER
+// area's block, so the recipe universe has to be complete before any item is
+// judged: a single walk would test each item against a half-built map, and
+// whether a cross-area reference resolved would depend on readdir order. That
+// is the kind of green that holds right up until someone renames a file.
+const parsed = [];
for (const file of files) {
- let doc;
try {
- doc = JSON.parse(readFileSync(join(AREAS_DIR, file), 'utf8'));
+ parsed.push({ file, stem: basename(file, '.json'), doc: JSON.parse(readFileSync(join(AREAS_DIR, file), 'utf8')) });
} catch (e) {
err(file, null, `does not parse as JSON: ${e.message}`);
- continue;
}
- const stem = basename(file, '.json');
+}
+
+// Keyed by FILENAME stem, deliberately not by `doc.area`. The two must agree
+// (checked per file below), and the stem is the half a `use` qualifier
+// actually names — `search:…` means `areas/search.json`. Keying by a
+// mismatched `doc.area` would let a qualified reference resolve THROUGH the
+// very inconsistency this gate reports one line down.
+const recipesByArea = new Map(parsed.map(({ stem, doc }) => [stem, areaRecipeKeys(doc)]));
+const recipeTotal = [...recipesByArea.values()].reduce((n, keys) => n + keys.length, 0);
+
+for (const { file, stem, doc } of parsed) {
if (doc.area !== stem) err(file, null, `"area" is ${JSON.stringify(doc.area)} but the filename says "${stem}"`);
if (typeof doc.title !== 'string' || !doc.title) err(file, null, 'missing "title"');
if (!Array.isArray(doc.items) || doc.items.length === 0) {
@@ -483,13 +630,6 @@ for (const file of files) {
continue;
}
- // Area-scoped: the universe a `provisioning.use` resolves against is THIS
- // file's recipe keys, so it is read once here rather than in the post-loop
- // cross-file section where `supersededBy` (whose universe is every id in the
- // ledger) has to live. Same reporting: `err(file, item.id, …)`.
- const recipeKeys = areaRecipeKeys(doc);
- recipeTotal += recipeKeys.length;
-
for (const item of doc.items) {
const id = typeof item.id === 'string' ? item.id : '';
const where = (msg) => err(file, id, msg);
@@ -528,9 +668,12 @@ for (const file of files) {
for (const msg of trapProblems(item, TRAPS)) where(msg);
- const useProblems = provisioningProblems(item, recipeKeys);
+ const useProblems = provisioningProblems(item, stem, recipesByArea);
for (const msg of useProblems) where(msg);
- if (item.fixtures?.provisioning !== undefined && useProblems.length === 0) recipeRefs++;
+ if (item.fixtures?.provisioning !== undefined && useProblems.length === 0) {
+ recipeRefs++;
+ if (parseUse(item.fixtures.provisioning.use).qualified) qualifiedRefs++;
+ }
if (item.status === 'retired') {
if (typeof item.retiredReason !== 'string' || !item.retiredReason) where('retired items must carry "retiredReason"');
@@ -727,6 +870,6 @@ const active = allItems.filter(({ item }) => item.status === 'active').length;
console.log(
`check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived;` +
` traps: ${TRAPS.size} documented, ${usedTraps.size} in use;` +
- ` provisioning: ${recipeTotal} area recipes, ${recipeRefs} item references resolved` +
+ ` provisioning: ${recipeTotal} area recipes, ${recipeRefs} item references resolved (${qualifiedRefs} area-qualified)` +
` (self-checks: ${trapControl.checked} trap-vocabulary + ${provisioningControl.checked} provisioning-resolve assertions).`,
);