Skip to content

Commit e5e7ee0

Browse files
os-zhuangclaude
andauthored
feat(spec): strictObject, the first registered-type conversions, and the check that found two live 422s (#4001) (#4514)
Three things, in the order they forced each other. 1. `strictObject` — closing a shape is one call. The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]`, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. 34 key arrays and 16 probe files, with most of the authorable surface still ahead. The array was never necessary. `knownKeys` feeds one thing — the edit-distance fallback — and the shape object is at the call site. `strictObject` reads the keys from `shape`, which also retires the per-schema probe: a list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay OPTIONAL; they carry judgement rather than transcription, and treating curation as a precondition is part of why this ratchet moved slowly. 2. A sharper target than "the five authorable directories". That triage answers "is this authorable?" but not "is this PARSED?" — and after #4410 the second question decides whether a flip enforces anything at all. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things, so the ledger now carries both. 3. The check, and the two live bugs it found immediately. `MetadataPlugin`'s loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc` here). Finding one thing three times is evidence the check is missing, so this adds it as an invariant over the registry. It found the fourth and fifth on its first run: `hook` and `datasource` had both gone strict in the #4001 data step WITHOUT the envelope, and were sitting on `main` in the hard-422 class. Three prior hand-searches for exactly this defect had walked past them. Both now declare it; no registered type rejects the envelope any more. The test asserts that case unconditionally — no exemption list — and tracks the quieter strip case (`field` only) separately, since each entry there becomes a rejection the day its schema closes. Also: the ledger gate caught its own blind spot again. `strictObject(` did not match its `z.object(` counting method, so the first conversion read as a site disappearing. Counting only `z.object(` would make "this directory got solved" and "this directory got deleted" produce the same number, so the method now counts both. Authoring impact on `seed` / `doc`: a key the schema never declared is rejected instead of silently discarded — it was already ignored, so no working behavior changes. Rejections name the surface, echo the key and suggest the closest declared one (`rows` → `records`, `body` → `content`), with tombstones for `path` / `slug` on `doc`. Published JSON Schema unchanged: output-mode conversion already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with the envelope gap — a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call. Tracked in the test's debt list and the ledger. Verified: spec 282 files / 7115 tests, `tsc --noEmit` clean, all 8 generated artifacts current, all 15 `check:*` gates green. Example-app seed definitions and doc frontmatter checked directly against the new shapes — only declared keys. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a84d41 commit e5e7ee0

15 files changed

Lines changed: 631 additions & 16 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`strictObject` makes closing an authoring shape one call; `seed` and `doc` are the first two registered metadata types converted with it; and a new invariant test found two live protection-envelope bugs on `hook` and `datasource`.
6+
7+
**The helper.** The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]` array, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. That was 34 key arrays and 16 probe files with most of the authorable surface still ahead — and the array was never necessary: `knownKeys` feeds only the edit-distance suggestion, and the shape object is right there at the call site. `strictObject({ surface, history, aliases?, guidance? }, shape)` derives it, which also removes the per-schema drift probe: a key list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay **optional** — they carry judgement rather than transcription, and treating curation as a precondition is part of why the ratchet moved slowly.
8+
9+
**A sharper targeting rule.** The five-directory triage answers "is this authorable?" but not "is this parsed?" — and after #4410 that second question decides whether a flip enforces anything. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness at all; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things.
10+
11+
**Two live bugs, found by a check rather than by reading.** `MetadataPlugin`'s artifact loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc`). A new invariant test over the registered-type registry found it twice more on its first run: **`hook` and `datasource` had both gone strict in the #4001 data step without the envelope.** Both now declare it. The test asserts the hard case (rejects) unconditionally with no exemption list, and tracks the quieter case (silently strips, currently only `field`) separately.
12+
13+
**Ledger.** `strictObject` replaces the old wiring recipe as the standard, and the gate's site-counting method now counts `strictObject(` alongside `z.object(` — counting only the latter would have made every conversion look like surface disappearing, so "solved" and "deleted" would read the same. The gate caught that itself on the first conversion.
14+
15+
Authoring impact: on `seed` and `doc`, a key the schema never declared is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names the surface, echoes the key and suggests the closest declared one (`rows``records`, `body``content`), with tombstones for `path` / `slug` on `doc`. The published JSON Schema is unchanged: `build-schemas.ts` converts with `io: 'output'`, which already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with a known envelope gap; it is a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call.

content/docs/references/data/datasource.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ const result = Datasource.parse(data);
4646
| **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` || Schema ownership mode |
4747
| **external** | `{ label?: string; allowedSchemas?: string[]; allowWrites: boolean; validation: object; … }` | optional | External datasource federation settings (schemaMode != "managed") |
4848
| **origin** | `Enum<'code' \| 'runtime'>` || Datasource provenance (server-managed, read-only) |
49+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
50+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
51+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
52+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
53+
| **_packageId** | `string` | optional | Owning package machine id. |
54+
| **_packageVersion** | `string` | optional | Owning package version. |
55+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
4956

5057

5158
---

content/docs/references/data/seed.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const result = Seed.parse(data);
3636
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` || Conflict resolution strategy |
3737
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` || Applicable environments |
3838
| **records** | `Record<string, any>[]` || Data records |
39+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
40+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
41+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
42+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
43+
| **_packageId** | `string` | optional | Owning package machine id. |
44+
| **_packageVersion** | `string` | optional | Owning package version. |
45+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
3946

4047

4148
---

content/docs/references/system/doc.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ const result = Doc.parse(data);
6868
| **order** | `number` | optional | Sort key within a book group (ADR-0046 §6) |
6969
| **group** | `string` | optional | Explicit book-group key (ADR-0046 §6); rules usually suffice |
7070
| **translations** | `Record<string, { label?: string; description?: string; content: string }>` | optional | Per-locale `{label?,description?,content}` variants; the base doc is the fallback |
71+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
72+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
73+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
74+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
75+
| **_packageId** | `string` | optional | Owning package machine id. |
76+
| **_packageVersion** | `string` | optional | Owning package version. |
77+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
7178

7279

7380
---

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ must explicitly `.strip()` back, because `.extend()` inherits `.strict()`.
2727

2828
## Standard wiring
2929

30-
`strictUnknownKeyError` in `shared/suggestions.zod.ts` (generalized from the
31-
#3746 hand-rolled map) is the one factory every strict authoring schema wires:
30+
`strictObject` in `shared/strict-object.ts` is the one call a strict authoring
31+
schema needs:
3232

3333
```ts
34-
z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases, guidance, history }) }).strict()
34+
lazySchema(() => strictObject({ surface, history, aliases?, guidance? }, { ...shape }))
3535
```
3636

3737
- `aliases` — semantic near-misses edit distance cannot reach (`visibleWhen`
@@ -40,8 +40,24 @@ z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases,
4040
rejection carries the upgrade — AGENTS.md Post-Task Checklist #3) and
4141
wrong-layer pointers (`apiOperations` is response-side; `objectName` belongs
4242
on the start node).
43-
- Key lists live beside the schema and are **drift-guarded by tests** (an
44-
"accepts every declared key" probe), because the schema body is lazy.
43+
- **Both are optional.** A schema with neither still names the surface, echoes
44+
the offending key, and suggests the closest declared one. Curation is an
45+
upgrade, not a precondition — and treating it as a precondition is part of
46+
why this ratchet moved as slowly as it did.
47+
48+
**No key list, and no drift probe.** Earlier steps hand-transcribed a
49+
`const X_KEYS = [...] as const` beside each schema and pinned it with an
50+
"accepts every declared key" test, because the schema body is lazy. That was 34
51+
key arrays and 16 probe files with most of the surface still ahead — and it was
52+
never necessary: `knownKeys` feeds only the edit-distance fallback, and the
53+
shape object is right there at the call site. `strictObject` reads the keys from
54+
`shape`, so the two copies become one and the probe has nothing left to catch.
55+
`extraKeys` covers the one case the shape cannot see: a base `.extend()`ed
56+
elsewhere.
57+
58+
`strictUnknownKeyError` stays exported for the schemas that cannot use the
59+
helper — notably `z.lazy()` discriminated unions, whose variants each need
60+
their own key set.
4561

4662
Every ratchet step ships only with the empirical zero-breakage pass: full
4763
`@objectstack/spec` suite + `tsc`, downstream consumer suites, and
@@ -128,6 +144,34 @@ dropped at parse, and nothing failed.
128144
`password` / `authSource` / `options` were **wired**, having been declared
129145
and dropped on the floor. Enforcing a contract and honouring it are the same
130146
task from two directions.
147+
8. **Three more registered types could not represent their own ADR-0010
148+
protection envelope**`seed`, `doc` and `validation`, found by applying the
149+
registered-type lens above. Exactly the gap that made `permission` return a
150+
hard 422 on the ADR-0094 overlay path (entry 2) and that `position` carried
151+
until step 2: `MetadataPlugin`'s artifact loader stamps `_packageId` /
152+
`_provenance` on **every** registered type, and `getMetaItemLayered`
153+
`saveMetaItem` round-trips a body carrying them, so an undeclared envelope was
154+
stripped on every parse. Declared on `seed` and `doc` as part of closing them;
155+
`validation` still carries it (its union shape defers the conversion).
156+
157+
Worth noting how it kept recurring: this was the **fourth** occurrence of one
158+
defect, found four times by four different routes, because nothing checked
159+
the invariant directly. So it is checked now —
160+
`kernel/metadata-type-schemas.test.ts` asserts it over the whole registry.
161+
162+
**It found two more on its first run.** `hook` and `datasource` had both gone
163+
`.strict()` in the #4001 data step *without* declaring the envelope, so both
164+
were in the worst class — rejecting their own loader's output, a live hard 422
165+
on the ADR-0094 overlay path, sitting on `main`. Three prior hand-searches for
166+
exactly this defect had walked past them. That is the argument for writing the
167+
check in one line: **finding the same defect repeatedly by hand is evidence
168+
the check is missing, not evidence the search worked.**
169+
170+
The check separates the two severities, because they are not the same bug:
171+
*rejecting* the envelope is live breakage and is asserted unconditionally with
172+
no exemption list; *stripping* it silently loses protection metadata on
173+
round-trip and is tracked with a debt list (`field` only) — and each entry
174+
there becomes a rejection the day its schema is closed.
131175

132176
This is the empirical argument for the ratchet: the inference "no metadata in
133177
the repo carries unknown keys" was **false three times over**, and only the
@@ -151,7 +195,7 @@ block) when `position` joined the ratchet.
151195

152196
## File-level triage — the five authorable directories
153197

154-
Site counts are `z.object(` occurrences per file (2026-07-30, this branch).
198+
Site counts are object sites — `z.object(` or `strictObject(` per file (2026-07-30, this branch).
155199
Classification is per the rule above; **(p)** marks a provisional call made
156200
from the file's exports/JSDoc rather than a full read — verify before
157201
tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
@@ -278,6 +322,20 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
278322
downstream risk is the lowest on the board); it is simply unstarted. If the
279323
step-1 question comes back "nothing is reporting", start here instead.
280324

325+
Done in the registered-types batch: `strictObject` (`shared/strict-object.ts`)
326+
replaced the four-part wiring recipe, and `seed` + `doc` became the first two
327+
conversions built on it — chosen by the registered-type lens above rather than
328+
by directory, so both are provably parsed as well as provably authored. Both
329+
also had to declare the ADR-0010 envelope, and the invariant test written in the
330+
same pass found `hook` and `datasource` rejecting it outright on `main`
331+
(findings log, entry 8). The ledger's site-counting method grew `strictObject(`
332+
in the same change, because the gate failed on the first conversion when it did
333+
not.
334+
335+
Deferred from that batch: `validation` — a `z.lazy()` discriminated union whose
336+
variants `.extend()` a shared base, so each variant needs its own key set rather
337+
than one `strictObject` call. It still carries the envelope gap.
338+
281339
Done in step 2: `security/rls.zod.ts` + `security/sharing.zod.ts` strict;
282340
`PositionSchema` strict with the protection envelope declared (closing the
283341
known sibling gap below).
@@ -356,10 +414,15 @@ the app step's `ACCOUNT_APP.defaultOpen` came from exactly this class of check.
356414
Liveness Check workflow) holds the two claims here that are mechanically
357415
checkable, so this map cannot go stale in silence again:
358416

359-
- **Site counts.** The method is stated above — `z.object(` occurrences per file
360-
— so every number in the triage tables is verifiable. A count that no longer
361-
matches means schemas were added or removed under a `Class` verdict nobody
362-
re-examined. Touching a file forces you back through this ledger.
417+
- **Site counts.** The method is stated above — `z.object(` or `strictObject(`
418+
occurrences per file — so every number in the triage tables is verifiable. A
419+
count that no longer matches means schemas were added or removed under a
420+
`Class` verdict nobody re-examined. Touching a file forces you back through
421+
this ledger. `strictObject(` had to join the count the moment the helper
422+
existed: counting only `z.object(` would have made every conversion look like
423+
surface *disappearing*, so "this directory got solved" and "this directory got
424+
deleted" would produce the same number. The gate caught that itself on the
425+
first conversion.
363426
- **Coverage.** Every `*.zod.ts` in a triaged directory that HAS sites must have
364427
a row. A new one is undeclared surface. The walk is **recursive**; nested files
365428
are declared by their path relative to the section directory

packages/spec/authorable-surface.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3211,6 +3211,13 @@
32113211
"data/DataTypeMapping:number",
32123212
"data/DataTypeMapping:text",
32133213
"data/DataTypeMapping:uuid",
3214+
"data/Datasource:_lock",
3215+
"data/Datasource:_lockDocsUrl",
3216+
"data/Datasource:_lockReason",
3217+
"data/Datasource:_lockSource",
3218+
"data/Datasource:_packageId",
3219+
"data/Datasource:_packageVersion",
3220+
"data/Datasource:_provenance",
32143221
"data/Datasource:active",
32153222
"data/Datasource:autoConnect",
32163223
"data/Datasource:capabilities",
@@ -3794,6 +3801,13 @@
37943801
"data/ScriptValidation:severity",
37953802
"data/ScriptValidation:tags",
37963803
"data/ScriptValidation:type",
3804+
"data/Seed:_lock",
3805+
"data/Seed:_lockDocsUrl",
3806+
"data/Seed:_lockReason",
3807+
"data/Seed:_lockSource",
3808+
"data/Seed:_packageId",
3809+
"data/Seed:_packageVersion",
3810+
"data/Seed:_provenance",
37973811
"data/Seed:env",
37983812
"data/Seed:externalId",
37993813
"data/Seed:mode",
@@ -5891,6 +5905,13 @@
58915905
"system/DistributedCacheConfig:prefetch",
58925906
"system/DistributedCacheConfig:tiers",
58935907
"system/DistributedCacheConfig:warmup",
5908+
"system/Doc:_lock",
5909+
"system/Doc:_lockDocsUrl",
5910+
"system/Doc:_lockReason",
5911+
"system/Doc:_lockSource",
5912+
"system/Doc:_packageId",
5913+
"system/Doc:_packageVersion",
5914+
"system/Doc:_provenance",
58945915
"system/Doc:content",
58955916
"system/Doc:description",
58965917
"system/Doc:group",

packages/spec/scripts/lib/strictness-ledger.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,23 @@
1010
import fs from 'node:fs';
1111
import path from 'node:path';
1212

13-
/** `z.object(` occurrences — the ledger's own stated counting method. */
13+
/**
14+
* Object sites — `z.object(` **or** `strictObject(` — the ledger's own stated
15+
* counting method.
16+
*
17+
* `strictObject(` counts because it *is* an object site; it is what a converted
18+
* schema looks like. Counting only `z.object(` would have made every conversion
19+
* silently shrink the ledger's measured surface, so a directory being solved and
20+
* a directory being deleted would read identically — and a genuinely new,
21+
* un-triaged `strictObject` schema would never register as undeclared surface.
22+
*
23+
* The gate caught this itself on the first conversion (`data/seed.zod.ts`, 1 → 0),
24+
* which is the behaviour to preserve: a change in how schemas are written must
25+
* fail this check rather than quietly rebase what it measures.
26+
*/
1427
export function countSites(file: string): number {
15-
return (fs.readFileSync(file, 'utf-8').match(/z\.object\(/g) ?? []).length;
28+
const src = fs.readFileSync(file, 'utf-8');
29+
return (src.match(/z\.object\(|(?<![A-Za-z0-9_])strictObject\(/g) ?? []).length;
1630
}
1731

1832
/**

packages/spec/src/data/datasource.zod.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { z } from 'zod';
99
*/
1010
import { lazySchema } from '../shared/lazy-schema';
1111
import { strictUnknownKeyError } from '../shared/suggestions.zod';
12+
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1213
import { validateDriverConfig } from './driver/config-registry.zod';
1314

1415
/*
@@ -611,6 +612,15 @@ export const DatasourceSchema = lazySchema(() => z.object({
611612
*/
612613
origin: z.enum(['code', 'runtime']).default('code')
613614
.describe('Datasource provenance (server-managed, read-only)'),
615+
616+
// ADR-0010 — runtime protection envelope (internal — set by the loader).
617+
// MISSING until the registered-type invariant test was written: `datasource`
618+
// closed strict in the #4001 data step without declaring it, so the
619+
// `_packageId` / `_provenance` that `MetadataPlugin` stamps on every
620+
// registered type were REJECTED here. Same live defect as `hook`, and the
621+
// same one `permission` hit as a 422 on the ADR-0094 overlay path before
622+
// Tier-A declared them (#4001 findings log, entries 2/8).
623+
...MetadataProtectionFields,
614624
}, { error: datasourceUnknownKeyError }).strict().superRefine((ds, ctx) => {
615625
// The `config` gate (#4410). `config` is parsed against the contract for the
616626
// declared driver and every issue is re-pathed under the slot it came from —

packages/spec/src/data/hook.zod.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod';
99
*/
1010
import { lazySchema } from '../shared/lazy-schema';
1111
import { strictUnknownKeyError } from '../shared/suggestions.zod';
12+
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1213
import { HookBodySchema } from './hook-body.zod';
1314

1415
/*
@@ -276,6 +277,14 @@ export const HookSchema = lazySchema(() => z.object({
276277
* - log: Log error and continue
277278
*/
278279
onError: z.enum(['abort', 'log']).default('abort').describe('Error handling strategy'),
280+
281+
// ADR-0010 — runtime protection envelope (internal — set by the loader).
282+
// MISSING until the registered-type invariant test was written: `hook` closed
283+
// strict in the #4001 data step without declaring it, so the `_packageId` /
284+
// `_provenance` that `MetadataPlugin` stamps on every registered type were
285+
// REJECTED here — the same live 422 that `permission` hit on the ADR-0094
286+
// overlay path before Tier-A declared them (#4001 findings log, entries 2/8).
287+
...MetadataProtectionFields,
279288
}, { error: hookUnknownKeyError }).strict());
280289

281290
/**

0 commit comments

Comments
 (0)