fix(spec): the manifest permissions block names its surface and offers the rename (#16328) - #16846
Conversation
…ers the rename `PluginPermissionsSchema` was the one closed object among the three known "strict object inside a union" doors that never adopted `strictObject`. Born `.strict()` with the ADR-0025 plugin-distribution work, it never passed through the unknown-key campaign, so its refusal was zod's own bare `Unrecognized key: "hoooks"` — the key echoed, but no surface and no rename, while every neighbouring block on the same manifest carried all three. The cause reported on the card — `formatZodError` flattening the union's nested refusal away — is false on this base. `formatZodIssue` descends `invalid_union` and ranks the arms through `selectUnionBranches` (`shared/union-branch-policy.ts`), dropping the `z.array(z.string())` arm as kind-mismatch-only and rendering the object arm verbatim. That flattening was lifted at #4971 and consolidated at #8318, and the strictness ledger's `state-machine.zod.ts` row already records it as spent. So the union is deliberately untouched: reshaping it costs either the accept set or the published JSON Schema and buys a message the author already has. The accept set does not move. `strictObject` is `z.object(shape, { error }).strict()`: same shape, same strictness, plus an error map consulted only once an issue is already being raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4301a68aafa8396777498f4f75c9114d197034c7 && git checkout 4301a68aafa8396777498f4f75c9114d197034c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c5ea982d971cb2550d35a845694a6cbc2575ea52 b5bbc0e5b95a6051bc4a5e00691ab445bde82934 && git checkout -B drift-repro c5ea982d971cb2550d35a845694a6cbc2575ea52 && git merge --no-ff b5bbc0e5b95a6051bc4a5e00691ab445bde82934
node scripts/docs-audit/affected-docs.mjs --json c5ea982d971cb2550d35a845694a6cbc2575ea52
|
Contract review — VERDICT: PASS WITH FINDINGS, no FAIL row (at
|
| alias | reviewer's measurement |
|---|---|
filesystem → fs |
maxDistance 3, nearest real key 8 ⇒ genuinely unreachable ✓ |
paths → fs |
maxDistance 2, nearest 4 ⇒ genuinely unreachable ✓ |
hosts → network |
hooks sits at distance 2, exactly its maxDistance |
So without the alias an author typing hosts would be misdirected to hooks — a worse outcome than no suggestion, and on the one key that decides which lifecycle hooks a plugin may register. The alias is therefore better justified than the PR claims, but the justification written into the code comment (manifest.zod.ts:50-52) and into the changeset — "Three spelled-out near-misses that edit distance cannot reach" — is false for hosts, and only filesystem is pinned.
CHANGELOG.md, so that sentence is published prose. Corrections have been sent to the dev.
The twelve rows
| # | Row | Reading | Finding |
|---|---|---|---|
| 1 | Card premise | READ | False, as the dev reported. renderIssue (shared/error-map.zod.ts:196-235) descends invalid_union via issue.errors; selectUnionBranches (shared/union-branch-policy.ts:186-213) drops the z.array(z.string()) arm as kind-mismatch-only and renders the object arm's unrecognized_keys line verbatim ⇒ the formatted channel already carried the key on the base. The card measured the raw top-level error.issues list, keyless by construction — not what an author reads. |
| 2 | Accept set (decisive) | READ | See above — Clause-②: no confirmed, must not be restored to yes. |
| 3 | Public surface | READ | check:authorable-surface serializes z.toJSONSchema, which emits additionalProperties:false from the unchanged .strict() and the unchanged .describe() and never an error map; check:api-surface snapshots export name/kind rows plus defineX signature hashes, and z.object(shape, params).strict() has the same TS type as z.object(shape).strict(). Neither could move, and the diff contains no authorable-surface/ or api-surface/ artifact. |
| 4 | Six-door table | READ | The change adds an error map to one ZodObject instance; the other five doors are separate strictObject instances with their own maps, renderIssue's seen set is per top-level issue, and the only shared state (declarationStore()) is an append-only audit registry no message reads ⇒ no other door's text can move. Both negative controls still pass by construction. |
| 5 | Ruling item 4 (ActionRef/GuardRef) |
READ | Already correct — both object arms are strictObject, state-machine.test.ts:307-314 pins the union rendering, and the ledger row carries the #4971 lift. ⭐ Provenance: that ledger line was added by #16766 (941232040, 2026-09-08) — after this card was filed on 2026-09-06. ⇒ the stale restatement is the card's and this seat's dispatch order's, not the repo's; the card was true when filed. |
| 6 | The three aliases | MEASURED | See the table above. None of the three is a spelling any published doc promises acceptance of (ADR-0025 lines 150-153 spell fs/network) ⇒ correction, not admission, is the right treatment. |
| 7 | Scope | MEASURED | Exactly the three files; nothing beyond the card, and the union itself is untouched as the #14722 guard requires. |
| 8 | Changeset level | READ | patch is honest per the workflow's own WHICH LEVEL prose (pr-automation.yml:667-676): a fix( that changes no public surface stays patch, and row 2 establishes no key entered the accept set and no export moved — only the text of an already-occurring refusal changed. |
| 9 | Releases / governed paths | MEASURED | Grep of the merge-base file list against `^(content/docs/releases/ |
| 10 | Ablation, by reading | READ | Exactly three of the six new tests depend on the change (the surface assertion, the Did you mean assertion inside the union, and the spelled-out-abbreviation leg), while "accept set does not move", "every declared key is accepted alone", "five doors untouched" and all 19 pre-existing tests exercise only the accept set or other doors and must stay green. That 3-red/22-green split is the one that must occur — derived here, not re-measured. |
| 11 | Sibling #16845 | READ | Genuinely out of scope: ProtectionSchema (shared/protection.zod.ts:63-106) is a different file reached from five authorable schemas, none of them the manifest; folding it in would widen this PR past its card and past the #14722 rule that a population sweep needs its own card. ui/app.zod.ts:1556. |
| 12 | CI | NOT MEASURED | PM-held gate — not polled, no timer, no watcher. |
PM disposition
- ⛔
needs:contract-reviewstays until the corrected head has its own at-tier reading — the three non-blocking corrections move the head. - The fifth consumer has been added to The
protectionblock's unknown-key refusal carries zod's bare message — no surface, no declared keys, no rename, on four authorable types #16845 so its triage is not graded off an undercount. - Nothing here disturbs the
Clause-②: nodeclaration or thepatchlevel; both were settled on measurement and this review re-derived them independently.
Generated by Claude Code
Contract review (
|
| ruling required | delivered |
|---|---|
| choose a route and argue it before code, in the PR body | done — the body's first section is the route and the measurement behind it |
| ⛔ do not default to patching this site | not defaulted: the PR measured formatZodError first (the reading the ruling said it had not done itself) and found the shared treatment already landed (#4971, consolidated #8318) |
if shared route: cover ActionRef/GuardRef |
measured as already correct on the base; no change needed — I confirm both arms are strictObject at the base and the ledger row records the lift |
acceptance fixture names hoooks with the rename; clean twin still ACCEPTED |
both pinned in manifest-unknown-keys.test.ts and measured by me (below) |
| re-run the six-door table | done per body; five doors + two negative controls byte-identical, one line changed |
The ruling's "shared treatment at formatZodError" was predicated on the card's cause, and the card's cause is false — verified from code at the merge-base: error-map.zod.ts:198-221 renderIssue takes issue.errors for invalid_union and runs selectUnionBranches (union-branch-policy.ts:191), which drops branches that are only invalid_type/invalid_value (line 147) and prefers unrecognized_keys (line 153). The card measured the raw error.issues list, which is a keyless invalid_union by construction; the formatted channel already carried the key. Adopting the repo's shared strictObject helper at the one site that never had it is the class treatment in the form this repo uses. Judged: implemented, not sidestepped.
Provenance note the prior seat already made and I concur with: the ledger row's "LIFTED / spent" sentence landed in #16766 on 2026-09-08, after the card was filed on 2026-09-06 — the card was true when written; the stale restatement is the dispatch's, not the repo's.
Numbered verification
-
Files vs merge-base
f89812e4d: exactly 3, matching the claim —.changeset/permissions-block-named-refusal.md(+18),packages/spec/src/kernel/manifest-unknown-keys.test.ts(+109),packages/spec/src/kernel/manifest.zod.ts(+27/−13). One commit. Governed paths: none — grep of the file list against^(content/docs/releases/|docs/adr/|\.claude/|skills/|AGENTS\.md$|CLAUDE\.md$)returns 0. Noauthorable-surface/,authorable-surface.base.jsonorapi-surface/artifact moves in the diff (git diff --staton those paths is empty). -
Clause-② from code —
noholds.strictObject(options, shape)isz.object(shape, { error: strictObjectError(options, shape) }).strict()(strict-object.ts:327-329, unchanged base→head).options.aliasesflows only intostrictUnknownKeyError(suggestions.zod.ts:418-431), where it is stored asaliases[aliasProbe(key)] = canonicaland read solely inside the returned$ZodErrorMapunderissue.code === 'unrecognized_keys'(line 433 returnsundefinedfor every other code). No parser path consults it. The shape is byte-identical modulo indentation:services/hooks/network/fs, all.optional(), same.describe()s; the.describe('Structured plugin permission grants (ADR-0025 §3.2)')is kept. ThestrictObjectimport already existed at line 6 (forPluginEnginesSchemaand the rest).
Measured, not just read — with zod 4.4.3 (the spec's pin) in a scratch dir, the before/after schemas built with the exact shape and a verbatim port of the error map, inside az.union([z.array(z.string()), P])as on the manifest:- clean control, legacy
['read','write'],[], all-four-keys, and each declared key alone: ACCEPT before, ACCEPT after (8/8 same). filesystem,paths,hosts: REFUSE before, REFUSE after — they gain a rename hint, not admission.Object.keys(shape)identical before/after;z.toJSONSchemaoutput byte-identical (additionalProperties:falsefrom.strict(), no error map emitted) — which is whycheck:authorable-surface(build-schemas.ts --check, a JSON-schema projection) could not move and did not.
⇒ the accepted key set isshapealone; nothing widens, nothing narrows. The declaration flipyes → no(card5583670163, superseding5582583449) is correct.
- clean control, legacy
-
Legacy flat-list arm —
ManifestPermissionsSchema = z.union([z.array(z.string()), PluginPermissionsSchema])at headmanifest.zod.ts:76-79, untouched;['read','write']and[]accepted (measured, and pinned in the "accept set does not move" test). -
Changeset
patch— correct. Perpr-automation.yml:667-672(batch [WIP] Add query enhancements and advanced validation features #35 WHICH LEVEL): "afix(that changes no public surface stayspatch". Item 2 establishes no key entered the accept set, no export moved, no JSON-schema projection moved; only the text of an already-occurring refusal changed.check-changeset-no-major.mjs's level axis (line 989) passes on anodeclaration;packages/spec/src/**is visible to the axis, so ayeswould have refusedpatch— the declaration and the level are now consistent (the dev measured the gate exit 0 against the live body; see F2 for why CI has not re-read it). -
Tests — 25
it(in the file at head (19 pre-existing + 6 new). No.skip/.only/.todo(grep exit 1). Reading the six new tests: exactly three depend on the change — (a) the surface +Did you meanassertions in "the author reads the key, the surface and the rename", (b)nested!.messagecontains the rename in "carried inside the union issue", (c)filesystem → fs; the other three ("accept set does not move", "every declared key accepted alone", "five doors untouched") and all 19 pre-existing tests exercise only the accept set or other doors. That is the 3-red/22-green split the body reports — derived here, not re-run (no checkout permitted to this seat).
Six-door byte identity, spot-checked by measurement: with the per-instance error map added to the permissions object only,engines(protocl) and adevPlugins[]-shapedz.array(z.union([Root, z.string()]))(namesapce) produce identicalissuesJSON before and after, and the string arm still accepts. Zod 4 resolves a message from the raising instance's ownerror, so a map on oneZodObjectcannot reach a sibling door. -
CI on head
b5bbc0e5b— 41 check runs: 33 success, 6 skipped (Console Pin Gate, Build Docs, packed-tarball ×2, Auto Label, Check PR Size — all path/opt-in skips), 2 failure:Check Changesetin runs34215028151(opened, 10:21:43Z) and34215041619(labeled, 10:21:52Z). Logs for both:⛔ This PR declares clause-② YES and grades a package it grew patch/declaration line: Clause-②: yes. Test Core 6/6, Dogfood 3/3, Temporal, Type Check ×5, Lint & Repo Gates, Governed Surface Queue Guard, Spec property liveness: all green.mergeable_state: unstableis exactly these two. Head is 9 commits behind main; none of those touch the three PR files (git diff --statof the symmetric range on those paths is empty) — the queue rebase should be clean.
Findings
F1 — non-blocking, published prose. The changeset (ships to CHANGELOG.md) says "Three spelled-out near-misses that edit distance cannot reach are curated as aliases". Measured with the repo's own levenshteinDistance/findClosestMatches and the max(2, floor(len/3)) budget: filesystem (budget 3, nearest 8) and paths (budget 2, nearest 4) are unreachable — but hosts (budget 2) is distance 2 from hooks, so without the alias the fallback would suggest hosts → hooks, misdirecting the author to the lifecycle-hooks key. The hosts alias is therefore better justified than the changeset says (it overrules a confidently wrong suggestion, the same shape the ledger records for itemVariable → indexVariable), but the sentence as published is false for one of the three. The code comment (manifest.zod.ts:50-52) is scoped to fs and is true. Only filesystem is pinned by test; paths and hosts are not. Concurs with the prior seat's finding; the fix is one sentence in the changeset plus, optionally, two more assertions.
F2 — CI, needs a re-trigger, not a code change. Both Check Changeset runs read the then-live body (Clause-②: yes) at 10:21–10:22Z; the body was corrected at ~10:29Z. pr-automation.yml triggers on [opened, synchronize, reopened, labeled, unlabeled] — no edited — so the red is permanent on this head until a labeled/unlabeled event or a push. The gate reads the live body and the live label list, so a re-run against the current state should pass (the dev measured exit 0 offline). This is the PM/maintainer's lever; do not push a no-op commit to clear it.
F3 — cosmetic. The new history: text ("This block has refused unknown keys since it was introduced, but through zod's own bare message…") is honest but does not fit the option's documented meaning (strict-object.ts:114: "what silently happened before this shape was closed") — nothing was ever silent here. Harmless; the sentence is emitted verbatim at the end of every refusal on this block.
Maintainer-only merge: no — not required.
Nothing in this diff moves a governed path, a public surface, or the accept set. Ordinary queue flow applies once Check Changeset has been re-run green on the live body (F2). Whether needs:contract-review is cleared on the strength of this reading is the review seat's / PM's call; this seat only records that the path-limb review the label demands has now been done at tier on b5bbc0e5b, and that a head bump for F1 would need only the changeset re-read, not a second full pass.
Generated by Claude Code
Two independent at-tier reviews now agree — and the operative fact is that this PR cannot enqueue as it stands
Concurring, not duplicating. The isolated seat's review ( ⭐ They also found F1 independently, with the same measurement:
|
Correction to my previous comment: the deadline is the release, not the merge — and that makes the other seat's "non-blocking" call the right one
What I said: that the What is actually true: a changeset stays unconsumed in ⭐ This is the same window card #16671 is working inside, on a different unconsumed changeset. That card is the proof the window exists. What still holds, unchanged
What this seat is doing, and not doing
Generated by Claude Code |
Second correction from this seat: the reds were already green when I called this PR un-enqueueable
At 10:55:51Z I wrote that this PR "cannot enqueue as it stands" and that "no amount of waiting changes that". The first half was false at the moment I posted it. Measured now, the The third run started four seconds after the What was right and what was wrong. The isolated seat's F2 was correct on its own terms — Why I got it wrong, stated plainly so it is checkable rather than apologetic: I carried a check reading taken at 10:41Z into a claim written at 10:55Z, without re-reading the channel that would carry the change. That is the same failure I corrected earlier today on a different PR — asserting a state from an earlier reading instead of from the surface that would show it had moved. ⇒ Nothing in this correction asks anyone to act. This PR is 34/34 green by a name-aggregated reading, sitting in the queue at Generated by Claude Code |
Clause-②: no
Fixes #16328
The route, and why — the choice the ruling asked for before any code
The card framed two routes: a per-site fix (reshape the
permissionsunion) or a shared treatment of "a closed object inside az.union", most likely atformatZodError. I took neither, because measuring first showed the shared treatment already exists and already works, and the union is not what loses the message.The card's stated cause is false on this base. Re-measured on
origin/mainf89812e4d, through a freshly builtdist/:formatZodErrordoes not flatten the nested refusal away.formatZodIssuedescendsinvalid_unionand ranks the arms throughselectUnionBranches(shared/union-branch-policy.ts), dropping thez.array(z.string())arm as kind-mismatch-only and rendering the object arm verbatim. The card measured the raw issue list, which is keyless; that is not what the author reads.This is not a new discovery — it is written down in this repo already. The strictness ledger's
state-machine.zod.tsrow says so in as many words:So the class-wide fix landed at #4971 and was consolidated at #8318. Rebuilding it at
formatZodErrorwould be a second copy of a working mechanism, and — as the ruling warned — it would change the message shape of every union refusal for no gain.What was actually wrong is one level in. Of the three known "closed object inside a union" doors,
PluginPermissionsSchemawas the only one that never adopted thestrictObjecthelper. Born.strict()with the ADR-0025 plugin-distribution work (#1487), it never passed through the #4001 unknown-key campaign, so its nested line was zod's own bareUnrecognized key: "hoooks"— key echoed, but no surface and no rename. Its two siblings carry all three, because their object arms arestrictObject:devPlugins[](#14722)ManifestSchema—strictObjectActionRef/GuardRef(ledger row)strictObjectpermissions(this card)z.object().strict(), no error map⇒ The fix is to adopt the repo's landed shared mechanism at the one site that never did. That is the class treatment, in the form this repo actually uses; the union is deliberately untouched.
On ruling item 4 —
ActionRef/GuardRefThe ruling required covering them if I chose the shared route. Measured on the base head, they are already correct and need no change:
The card's "second known door, reported but not fixed" is stale; the ledger already records it as spent.
The change
PluginPermissionsSchemanow usesstrictObject, likePluginEnginesSchemathree declarations below it in the same file. Three spelled-out near-misses edit distance cannot reach are curated as aliases:filesystemandpathspoint atfs,hostspoints atnetwork.After:
The top
Invalid inputwrapper line stays. That is the union's one-line cost and it is the same shapedevPlugins[]andActionRefalready have — the ledger's own words: "The union's cost is one wrapper line, not a lost message."The hard acceptance limb: the five correct doors are byte-identical
The six-door table was re-run on the base head and again after the change, same harness, same fixtures, both through a freshly built
dist/kernel/index.mjs.diffof the two full transcripts is exactly one line — thepermissionsnested message:Manifest root,
contributes,contributes.kinds[],enginesand the legacyengineare unchanged to the byte, as are both negative controls.Accept set: no movement (Clause-②)
permissions clean (control): ACCEPTED, before and after — the card's negative control, preserved.permissionslegacy flat list['read','write']: ACCEPTED, before and after.pnpm --filter @objectstack/spec check:generated— all 15 artifacts up to date,check:authorable-surfaceamong them. That is the mechanical proof the authorable key set did not move.strictObjectisz.object(shape, { error }).strict(). Same shape, same strictness; an error map is consulted only once an issue is already being raised.On the declaration. This PR was claimed with the opposite verdict on that leading line; the
domain:specseat withdrew it and superseded it at the producer (card #16328, comment5583670163) after verifying the measurement above independently — the accepted key set isshapealone,shapeis byte-identical across this diff, andoptions.aliasesis consumed only inside the$ZodErrorMap, sofilesystem/paths/hostsgained a rename hint and not admission. The changeset stayspatchbecause no published surface grew.needs:contract-reviewstays regardless: the enqueue gate's path limb (packages/spec/src/**) is hit whatever the declaration says, so an at-tier review of this diff is still owed before enqueue.Reverse verification (one-off, not left in the tree)
Fix committed first (
b5bbc0e5b), then ablated back to the barez.object().strict():0, injected anchor count1, blob786ec804…to70da3f23…;pnpm --filter @objectstack/spec build, thennode scripts/ablation-dist-preflight.mjs packages/spec '…' --absent→ exit 0, "marker absent from all 218 built files";git checkout HEAD -- …, whole-treegit status --porcelainempty, blob back to786ec804…, rebuilt, preflight (present) exit 0, 25/25 green.Verification
pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec check:generatedpnpm lint(whole repo,eslint . --no-inline-config)dispatch-gates --ranreconciliationThe 3 unrun families are NOT MEASURED, not red — each refuses on its own declared prerequisite (a full workspace build this worktree does not have) and says so itself:
check:dual-build-cjs-loads,check:type-check-debtand@objectstack/lint check:doc-formula-expressionsall exit 3 withPREREQUISITE NOT MET/ "This is NOT a pass: nothing was measured". CI builds the closure before those steps. Every exit code above was captured before any pipe.验收备注
protectionblock's unknown-key refusal carries zod's bare message — no surface, no declared keys, no rename, on four authorable types #16845 —ProtectionSchema(shared/protection.zod.ts:63) is a barez.object().strict()with no error map, so{ protection: { lockk: 'system' } }on anagentrefuses with zod's bareUnrecognized key: "lockk"— no surface, no rename. Same underlying gap as this card, different file, and reached fromagent/tool/skill/position. Not fixed here: out of this card's file surface. The strictness ledger has no row for it, which is itself worth confirming at triage.stack.devPlugins[]hidesManifestSchema's named unknown-key refusal underinvalid_union— an author sees a keyless "Invalid input" at that one door #14722's triage ruled that a sweep of the remaining union and strictness sites needs its own card with a measured population. This PR does not perform one, and Theprotectionblock's unknown-key refusal carries zod's bare message — no surface, no declared keys, no rename, on four authorable types #16845 does not either — it reports a single measured site.Authored by Claude Code in session
session_016N6xmWt5hYm94ffVEwGH8x(https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x).Generated by Claude Code