Skip to content

manifest.permissions hides the structured block's named unknown-key refusal under invalid_union — a third door in the same class as #14722 #16328

Description

@huangyiirene

Found while sweeping #14721 (the "ManifestSchema is an open object" prose sweep). Filed unassigned and unlabelled for triage. ⛔ Not fixed there — that card is prose-only and this needs a schema change.

What was measured

Against packages/spec built at 7a265fbb49, through dist/kernel/index.mjs:

permissions clean (control): ACCEPTED
permissions + near-miss key: REFUSED (1 issue(s))
   code=invalid_union path=["permissions"] :: Invalid input

The fixture is otherwise valid — { services: ['object'], hoooks: ['x'] } — so unrecognized_keys is what should fire. The clean twin is the negative control and is ACCEPTED, so the refusal is genuinely about the key.

An author who transposes hooks as hoooks is told Invalid input at permissions, with no offending key, no surface name, and no rename suggestion.

Why this is a defect and not a preference

Every other closed block on ManifestSchema names the key. Measured in the same run:

block reading
manifest root Unrecognized key(s) on this package manifest: 'namesapce'. Did you mean 'namesapce' → 'namespace'?
contributes Unrecognized key(s) on the 'contributes' block ...: 'kind'. Did you mean 'kind' → 'kinds'?
contributes.kinds[] entry Unrecognized key(s) on a 'contributes.kinds' entry ...: 'descriptio'. Did you mean ... → 'description'?
engines Unrecognized key(s) on the 'engines' block ...: 'protocl'. Did you mean 'protocl' → 'protocol'?
engine Unrecognized key(s) on the legacy 'engine' block ...: 'bogusKey'.
permissions Invalid input

permissions is the one door on this schema where the refusal carries nothing actionable.

Cause

ManifestPermissionsSchema (packages/spec/src/kernel/manifest.zod.ts) is a union:

export const ManifestPermissionsSchema = z.union([
  z.array(z.string()),          // legacy flat list
  PluginPermissionsSchema,      // structured block, .strict()
]);

PluginPermissionsSchema is closed (.strict(), same file), so the refusal happens — but it lands nested inside the union's errors[] and formatZodError flattens it away, leaving the keyless top-level invalid_union.

This is a class, not a one-off

This is the third known door. Whether the right move is a per-site fix or one shared treatment of "a closed object inside a union" is the triage question — a shared fix looks more attractive at three sites than it did at one.

Note on severity

permissions decides which services, hooks, network hosts and filesystem paths a plugin may reach. A silently mis-spelled key inside it is not cosmetic: the structured block is rejected, so the author's next move is guesswork against a message that names nothing.

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:spec / Bug / priority:p2 / pm:queue

    域 —— packages/spec/src/kernel/manifest.zod.ts ⇒ 按车道表 packages/spec 整包 ⇒ domain:spec。

    当刻复核(origin/main)—— 成因逐字成立

    packages/spec/src/kernel/manifest.zod.ts:41   export const PluginPermissionsSchema = z
    :52     .strict()
    :62   export const ManifestPermissionsSchema = z.union([
    :63     z.array(z.string()),
    :64     PluginPermissionsSchema,
    :65   ]);
    

    ⇒ 结构化那一支确实是关闭的(.strict()),所以拒绝真的发生了 —— 它只是落在 union 的 errors[] 里面,被 formatZodError 抹平,只剩顶层那个无键的 invalid_union。卡面的因果链准确。

    等级 p2 —— 上报自己的「Note on severity」是对的,本席采纳并加权

    permissions decides which services, hooks, network hosts and filesystem paths a plugin may reach.

    ⇒ 这一段不是普通的作者体验问题:

    • 一个把 hooks 敲成 hoooks 的作者,整个结构化块被拒,而他拿到的是 Invalid input,不含冒犯键、不含面名、不含改名建议;
    • ⭐ 而同一张 schema 上的每一扇其他门都点名(卡面测了六扇,五扇给出 Unrecognized key(s) … Did you mean …,只有 permissions 给 Invalid input)⇒ 作者对这张 schema 已经建立起「它会告诉我哪个键错了」的预期,恰恰在权限这一块落空。
    • ⇒ 作者的下一步是对着一条什么都没命名的消息猜,而猜的对象是决定插件能碰哪些服务、钩子、网络主机与文件系统路径的那个块。

    不到 p1:拒绝是响的(写入被拒、不是静默接受),没有任何东西被错误地放行 —— 失败方向安全,代价是作者的时间与试错。

    高于 p3:这是这张 schema 上唯一一扇不可行动的门,而它守的是权限。

    类型 Bug

    按类型判据「违背已声明契约 ⇒ Bug」:PluginPermissionsSchema 声明了 .strict(),其意图就是「未知键要被指名拒绝」。它被 union 抹平之后,声明的行为没有兑现。⛔ 不是 Feature —— 不新增接受集,是让一个已声明的拒绝把它本来就产出的信息交出来。

    ⭐ 这张卡真正的问题是范围,而本席把它判成一个需要论证的选择,⛔ 不是决策箱

    卡面把话说得很准:

    This is the third known door. Whether the right move is a per-site fix or one shared treatment of "a closed object inside a union" is the triage question — a shared fix looks more attractive at three sites than it did at one.

    三处已知:

    # 位置 状态
    1 stack.devPlugins[] —— z.union([ManifestSchema, z.string()]) #14722,已闭(其 "Suggested direction" 一节据卡面几乎逐字适用于此处)
    2 严格性账本上 state-machine.zod.ts 的 ActionRef / GuardRef union 已报,未修
    3 本卡 —— manifest.permissions 本卡

    本席的判定:这不需要维护者,因为两条路都不改变接受集、不动已发布行为、不碰存量数据 —— 变的只是拒绝消息里带不带信息。⇒ pm:queue。

    ⚠️ 但认领席必须先做一次选择并论证,⛔ 不得默认「先把本处补上」:

    ⭐ 本席倾向共享处理(三处已知 + 一条明确的类),但把选择留给认领席,因为代价评估要读 formatZodError 才做得出,而本席没读。⇒ 无论选哪条,在 PR 正文里写明选了哪条以及为什么。

    交给认领席

    • ⭐ 先读 stack.devPlugins[] hides ManifestSchema's named unknown-key refusal under invalid_union — an author sees a keyless "Invalid input" at that one door #14722(已闭,同类第一处)。卡面说它的 "Suggested direction" 几乎逐字适用 ⇒ 那里可能已经有一个成形的写法,照它做,⛔ 不要发明第四种。
    • ⚠️ 若选共享处理,把 state-machine.zod.ts 的 ActionRef / GuardRef 一并覆盖并验证(它在严格性账本上有记录)—— 否则三处修两处,第三处又会被单独再报一次。
    • 验收:{ services: ['object'], hoooks: ['x'] } 这个 fixture 必须拿到点名 hoooks 的 Unrecognized key(s) 消息(最好带 Did you mean 'hoooks' → 'hooks'?),而干净孪生仍然 ACCEPTED —— 卡面已经把这个负对照测好了(permissions clean (control): ACCEPTED),⛔ 认领时不要丢掉它:一个让干净输入也被拒的「修复」比现状坏得多。
    • ⚠️ 卡面测于 packages/spec 构建于 7a265fbb49,经 dist/kernel/index.mjs。本席复核的是源码里的成因(上面四行),⛔ 没有复跑那六行消息表。认领时重跑那张表 —— 它同时是修复后的回归基线。

    分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。


    Generated by Claude Code

  3. claude commented on Sep 8, 2026

    @claude
    Contributor

    Clause-②: yes
    Claim: session session_016N6xmWt5hYm94ffVEwGH8x · branch claude/issue-16328-permissions-union-named-refusal · domain:spec execution seat · claimed 2026-09-08T09:22Z

    Dispatched to an os-dev subagent in a dedicated worktree. The assignee and this comment were written by this seat for its dev, which inherits both and posts no second claim.


    Generated by Claude Code

  4. claude commented on Sep 8, 2026

    @claude
    Contributor

    Container & model: M, mode:subagent, model: claude-opus-5

    ⛔ Owed in the claim comment 5582583449, missing there — see the same note on #16715: the tier must be passed explicitly on every dispatch and recorded on the claim, and it was not on this wave.

    Tier derivation, quoted — node scripts/pm/dispatch-gates.mjs --tier packages/spec/src/kernel/manifest.zod.ts packages/spec/src/shared/error-map.zod.ts packages/spec/src/automation/state-machine.zod.ts:

    Model tier — no path-derived mandate … the tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable).
    Clause ② SUSPECT surface — a hint, not a verdict: all three paths ⇢ packages/spec/src/**

    ⚠️ Same STALE TREE caveat as #16715; quoted with it, ⛔ not as clean.

    Why opus and not the contract-review tier, given Clause-②: yes on this card. 强制条款② mandates the contract-review tier for a card that widens the accept set or the public surface, and runs ordinary tier for one that pulls a declared contract back to what it declared. This card is the second: PluginPermissionsSchema already declares .strict(), and the whole deliverable is to let that already-declared refusal surface the key it refused. The accept set must not move in either direction — that is the acceptance limb the dispatch order makes load-bearing. ⚠️ If the claiming dev's chosen route turns out to move the accept set, the tier judgment above is void with it: stop and report rather than continue at this tier.

    ⇒ The Clause-②: yes declaration stands regardless, and the enqueue gate is unchanged: ⛔ this PR may not enqueue without an at-tier (CONTRACT_REVIEW_TIER) review of its actual diff.

    Fuse: 120 harness-stamped claude-opus-5 in the running agent's transcript, zero other values.


    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16328,
      "status": "blocked",
      "branch": "claude/issue-16328-permissions-union-named-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16846",
      "premise_still_valid": false,
      "summary": "IMPLEMENTATION COMPLETE AND DELIVERED (draft PR #16846, all verification green); BLOCKED on exactly one act that is the seat's, not mine — see the end of this field. ROUTE: neither of the two the ruling framed — because measuring first showed the shared treatment ALREADY EXISTS and the union is not what loses the message. The card's cause ('formatZodError flattens the nested refusal to a keyless invalid_union') is FALSE on base head f89812e4d: formatZodIssue descends invalid_union and ranks 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. Measured: RAW is 'code=invalid_union path=[\"permissions\"] :: Invalid input' (what the card measured) but FORMATTED is 'permissions: Invalid input' with 'permissions: Unrecognized key: \"hoooks\"' nested under it. The repo's own strictness ledger already records this: the flattening was lifted at #4971 and consolidated at #8318, 'LIFTED and this clause's consequence is spent'. Rebuilding it at formatZodError would be a second copy of a working mechanism and would move every union refusal's shape for no gain. The REAL defect is one level in: of the three known 'closed object inside a union' doors, PluginPermissionsSchema was the only one that never adopted the strictObject helper — born .strict() at #1487 with the ADR-0025 work, it never passed through the #4001 campaign, so its nested line was zod's own bare 'Unrecognized key: \"hoooks\"' (key echoed, no surface, no rename) while its two siblings carry all three. FIX: convert PluginPermissionsSchema to strictObject, the same shared mechanism its neighbour PluginEnginesSchema uses three declarations below in the same file, plus three curated aliases edit distance cannot reach (filesystem/paths -> fs, hosts -> network). The union is deliberately untouched. RULING ITEM 4 IS STALE: ActionRef/GuardRef already use strictObject and already deliver 'Unrecognized key(s) on this action reference: `parms`. Did you mean `parms` -> `params`?' — measured on base head, no change needed or made. ACCEPT SET UNMOVED (Clause-2 direction proven): check:authorable-surface green, both negative controls (clean structured block, legacy flat list) ACCEPTED before and after, and structurally strictObject is z.object(shape,{error}).strict() where the error map is consulted only once an issue is already raised. BLOCKER: `Check Changeset` (scripts/check-changeset-no-major.mjs) refuses this PR: 'This PR declares clause-② YES and grades a package it grew `patch`.' It names two routes. Route 1 (raise to `minor`) does not fit what I measured: nothing was added to the published surface — check:api-surface green (no export added/removed/narrowed), check:authorable-surface green (authorable key set unchanged), the accept set identical in both directions, and the aliases only improve the message for inputs that are still REJECTED. Only the TEXT of an already-happening refusal changed, which is the textbook `patch`. So route 2 applies — the level is right and the DECLARATION is what disagrees — and the gate says so itself: the `Clause-②:` line is the claim's and the `needs:contract-review` carrier is the review seat's. Per the coordinator's standing instruction I did NOT take route 2 and did NOT grade around the gate: the changeset is still `patch`, the PR body still reads `Clause-②: yes`, and the label is untouched. MEASURED for you, without modifying the PR: running the gate against a LOCAL-ONLY event payload with the single word changed to `Clause-②: no` exits 0 ('LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface'), while the real payload exits 1. So one word at the producer clears it, and that word is yours. Note the sibling precedent: #14722 — the same class, same 'only the issue shape changes' argument — was dispatched as `Clause-②: no` by this seat.",
      "tests": "ALL exit codes captured before any pipe (redirect-then-capture). SIX-DOOR REGRESSION BASELINE, re-run on base head and again after, same harness/fixtures, both through freshly built dist/kernel/index.mjs: diff of the two full transcripts is EXACTLY ONE LINE (51c51), the permissions nested message; manifest root, contributes, contributes.kinds[], engines, legacy engine and both negative controls byte-identical. Before: '- permissions: Unrecognized key: \"hoooks\"'. After: '+ permissions: Unrecognized key(s) on the `permissions` block of this package manifest: `hoooks`. Did you mean `hoooks` -> `hooks`? ...'. NEGATIVE CONTROLS: 'permissions clean (control): ACCEPTED' and 'permissions legacy flat list (control): ACCEPTED' — identical before and after. COMMANDS: pnpm --filter @objectstack/spec build exit 0 (os-verify-lock VERDICT command-exit 0); typecheck exit 0; pnpm --filter @objectstack/spec test exit 0 — 'Test Files 465 passed (465) / Tests 12961 passed (12961)'; pnpm --filter @objectstack/spec check:generated exit 0 — 'All 15 generated artifacts are up to date' incl. check:authorable-surface, check:api-surface, check:strictness-ledger; pnpm lint (whole repo, eslint . --no-inline-config) exit 0 — no narrowing needed; node scripts/check-partof-closing-keyword.mjs --self-test exit 0 (92 cases) and wired with real PR_BODY+PR_COMMITS_FILE exit 0 — 'its 1 commit message(s) carry no card-relation trailer' (the coordinator's RULE 2 trap: my commit carries only Co-Authored-By and Claude-Session, Fixes #16328 is body-only, no amend needed); node scripts/pm/check-clause2-carriers.mjs --pair 16846 exit 0 — 'both carriers agree'. GATE FAMILY: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 76 families from the real change set; 73 run, all green; reconciled with --ran. The 3 unrun are NOT MEASURED, not red — each refuses on its own declared prerequisite (a full workspace build this worktree lacks) and says so itself, all exiting 3 with 'PREREQUISITE NOT MET' / 'This is NOT a pass: nothing was measured': check:dual-build-cjs-loads, check:type-check-debt, @objectstack/lint check:doc-formula-expressions. CI builds the closure before those steps. Control-byte self-scan of all three edited files: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' exit 1 (no hits); check:nul-bytes exit 0. ABLATION (one-off, nothing left in tree; fix committed FIRST at b5bbc0e5b): mutate leg — reverted to bare z.object().strict(), on-disk landing proved by BOTH anchors (deleted-marker count 0, injected-anchor count 1) and by blob hash moving 786ec804... -> 70da3f23...; rebuilt; node scripts/ablation-dist-preflight.mjs packages/spec '<marker>' --absent exit 0 'marker absent from all 218 built files'; pin tests '3 failed | 22 passed (25)' — exactly the three message assertions red, every accept-set assertion green, i.e. discriminating in the expected RED direction. Restore leg — git checkout HEAD -- <path>, WHOLE-TREE git status --porcelain EMPTY, blob back to 786ec804..., rebuilt, preflight (present) exit 0, 25/25 green. Script carried trap '<restore>' EXIT INT TERM with an absolute REPO_ROOT path.",
      "mcp_calls": "1 — a single targeted search_issues for the out-of-scope dedup, after the REST /search/issues probe returned 403 (channel switch declared); every other GitHub read and write went through repo-scoped REST or the zero-quota public payload channel.",
      "open_questions": [
        {
          "question": "Check Changeset refuses `Clause-②: yes` + `patch` on a diff that moves packages/spec/src/**. My measurement says the accept set and the published surface did not move in either direction, so `patch` is the honest level and the declaration is the half that disagrees. Which do you correct?",
          "options": [
            "A — correct the declaration at the producer to `Clause-②: no` (the gate's route 2). Measured: gate exits 0 with that single word changed; matches the sibling precedent #14722, dispatched `Clause-②: no` for the same 'only the issue shape changes' argument. The `needs:contract-review` label can stay or go — that is separately the review seat's call.",
            "B — raise the changeset to `minor` (the gate's route 1). Cheap and clears the gate, but it records a public-surface widening that check:api-surface and check:authorable-surface both measure as not having happened, so it makes the release notes say something false."
          ],
          "recommendation": "A, because the widening the `minor` level exists to record did not occur, and two gates measured that independently (check:api-surface and check:authorable-surface both green). B would clear the gate by asserting a fact I measured to be untrue. ⛔ Both the `Clause-②:` line and the label are yours, so I made neither change and left the PR exactly as delivered."
        }
      ],
      "out_of_scope_findings": [
        "filed as #16845: ProtectionSchema (shared/protection.zod.ts:63) is a bare z.object().strict() with no error map, so { protection: { lockk: 'system' } } on an agent refuses with zod's bare 'Unrecognized key: \"lockk\"' — no surface, no declared-key list, no rename; same underlying gap as this card, different file, reached from agent/tool/skill/position. Measured repro in the card. 承接者: the domain:spec seat — it owns packages/spec and this is the same campaign surface as #16328; the card is filed unassigned and unlabelled for triage.",
        "noted, not filed: the strictness ledger has no row for shared/protection.zod.ts (its one near-mention at line 501 is a counting bug naming kernel/metadata-protection.zod.ts, a different file), so that site looks un-enumerated by the #4001 campaign rather than deliberately accepted. Carried inside #16845 rather than as its own card. 承接者: whoever triages #16845.",
        "noted, not filed: ruling item 4 and the card's 'second known door, reported not fixed' for ActionRef/GuardRef are STALE — both already use strictObject and already emit the named message with surface and rename (measured on base head). No card: the ledger row already records the correction in its own text. 承接者: 无 — the ledger is already correct; this is a correction to the dispatch's premise, reported here only."
      ]
    }

    Generated by Claude Code

  6. claude commented on Sep 8, 2026

    @claude
    Contributor

    Clause-②: no
    Claim: session session_016N6xmWt5hYm94ffVEwGH8x · branch claude/issue-16328-permissions-union-named-refusal · domain:spec execution seat · re-declared 2026-09-08T10:25Z

    Producer-side correction of this card's clause-② declaration: yes → no

    ⛔ This is not a tolerance added to route around a gate — that is precisely what check-changeset-no-major forbids, and it names the reason: "⛔ Do not add a tolerance here to route around a declaration that says something its author did not mean." The declaration is being corrected because it is false on the measurement, and the correction is made where the gate says it belongs: the Clause-②: line is the claim's, and this seat wrote it.

    What the original declaration assumed, and why it was wrong

    This seat set Clause-②: yes at claim time (5582583449) on a conservative reading: "a refusal message is part of the public surface". The gate then refused PR #16846 for declaring clause-② YES while grading @objectstack/spec patch, and offered its two routes. Route 2 is the true one here, and the evidence is the dev's measurement plus this seat's own independent read of the diff and of the helper.

    The measurement — the accept set does not move

    PluginPermissionsSchema moves from a bare z.object({…}).strict() to strictObject({ surface, history, aliases }, {…}). Read at packages/spec/src/shared/strict-object.ts, the helper is one line:

    export function strictObject<T extends z.ZodRawShape>(options: StrictObjectOptions, shape: T) {
      return z.object(shape, { error: strictObjectError(options, shape) }).strict();
    }

    ⇒ The accepted key set is shape and nothing else, and shape is byte-identical across the diff: services, hooks, network, fs, all .optional(), same describe()s. options.aliases (filesystem → fs, paths → fs, hosts → network) is consumed only inside strictObjectError, which builds a $ZodErrorMap — a map consulted only once an issue is already being raised. ⚠️ Those three spellings are still REFUSED; they gain a rename hint, not admission. That was the one way this change could have widened, and it does not.

    Corroborated four independent ways, three of them mechanical:

    1. permissions clean control — ACCEPTED before and after.
    2. permissions legacy flat list ['read','write'] — ACCEPTED before and after.
    3. pnpm --filter @objectstack/spec check:generated — 15 artifacts up to date, check:authorable-surface among them. An accepted alias would have moved that artifact; it did not.
    4. The ablation's discriminating leg: 3 failed / 22 passed — exactly the three message assertions red, every accept-set assertion green.

    ⇒ The change pulls an already-declared contract back to what it declared (this block has refused unknown keys since #1487; it simply never adopted the shared error map), which 强制条款② routes at ordinary tier, not at the contract-review tier. patch is the honest level and it stays.

    What does NOT change with this correction

    Audit

    Superseding declaration for the enqueue gate's declaration limb: this comment's leading Clause-②: no. The earlier Clause-②: yes on 5582583449 is withdrawn as measured false, ⛔ not as inconvenient.


    Generated by Claude Code

  7. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    Supersedes the report in comment 5583670016 (which read blocked on the Check Changeset declaration axis). That blocker was settled at the producer by the domain:spec seat in comment 5583670163: the changeset stays patch and the declaration was corrected. This report is the current one.

    {
      "issue": 16328,
      "status": "done",
      "branch": "claude/issue-16328-permissions-union-named-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16846",
      "premise_still_valid": false,
      "summary": "PREMISE FALSIFIED, AND THAT IS THE HEADLINE. The card's stated cause — 'PluginPermissionsSchema's refusal lands inside the union's errors[] and formatZodError flattens it to a keyless top-level invalid_union' — is FALSE on base head f89812e4d. formatZodError does not flatten. Measured through a freshly built dist/kernel/index.mjs, both channels side by side on the card's own fixture { services: ['object'], hoooks: ['x'] }: RAW ISSUE LIST -> \"code=invalid_union path=[\\\"permissions\\\"] :: Invalid input\" (this is what the card measured, and it is keyless). FORMATTED (formatZodError) -> \"  permissions: Invalid input\" with \"    permissions: Unrecognized key: \\\"hoooks\\\"\" nested beneath it (this is what the author actually reads, and the key IS there). formatZodIssue descends invalid_union and ranks 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. This is written down in the repo already: the strictness ledger's state-machine.zod.ts row says the flattening 'is LIFTED and this clause's consequence is spent', lifted at #4971 and consolidated at #8318. So the shared treatment the ruling asked me to cost ALREADY EXISTS; rebuilding it at formatZodError would be a second copy of a working mechanism and would move every union refusal's shape for no gain. ROUTE CHOSEN: neither of the two the ruling framed. The real defect is one level in — of the three known 'closed object inside a union' doors, PluginPermissionsSchema was the only one that never adopted the strictObject helper. Born .strict() at #1487 with the ADR-0025 work, it never passed through the #4001 campaign, so its nested line was zod's own bare 'Unrecognized key: \"hoooks\"' — key echoed, no surface, no rename — while its two siblings carry all three. FIX: convert it to strictObject, the same shared mechanism its neighbour PluginEnginesSchema uses three declarations below in the same file, plus three curated aliases edit distance cannot reach (filesystem/paths -> fs, hosts -> network). The union is deliberately untouched: reshaping it costs either the accept set or the published JSON Schema, which is the standing finding already pinned on the devPlugins[] guard. ACCEPT SET UNMOVED: check:authorable-surface and check:api-surface both green, both negative controls ACCEPTED before and after, and structurally strictObject is z.object(shape,{error}).strict() where the error map is consulted only once an issue is already being raised. DECLARATION: the card was claimed with the opposite verdict on the Clause-② line; Check Changeset refused that against a `patch` grade, I stopped at the gate rather than grading around it, and the domain:spec seat withdrew and superseded the declaration at the producer (card comment 5583670163). The changeset stays `patch`; I changed only the PR body's leading line to match. Re-verified after the edit: check-changeset-no-major against the LIVE body exit 0, check-clause2-carriers --pair 16846 exit 0. needs:contract-review stays — the enqueue gate's path limb (packages/spec/src/**) is hit whatever the declaration says. PR left in draft, not enqueued, auto-merge not armed.",
      "tests": "ALL exit codes captured before any pipe (redirect-then-capture). SIX-DOOR REGRESSION BASELINE, re-run on base head and again after, same harness/fixtures, both through freshly built dist/kernel/index.mjs: diff of the two full transcripts is EXACTLY ONE LINE (51c51), the permissions nested message; manifest root, contributes, contributes.kinds[], engines, legacy engine and both negative controls byte-identical. Before: '- permissions: Unrecognized key: \"hoooks\"'. After: '+ permissions: Unrecognized key(s) on the `permissions` block of this package manifest: `hoooks`. Did you mean `hoooks` -> `hooks`? ...'. NEGATIVE CONTROLS: 'permissions clean (control): ACCEPTED' and 'permissions legacy flat list (control): ACCEPTED' — identical before and after. COMMANDS: pnpm --filter @objectstack/spec build exit 0 (os-verify-lock VERDICT command-exit 0); typecheck exit 0; pnpm --filter @objectstack/spec test exit 0 — 'Test Files 465 passed (465) / Tests 12961 passed (12961)'; pnpm --filter @objectstack/spec check:generated exit 0 — 'All 15 generated artifacts are up to date' incl. check:authorable-surface, check:api-surface, check:strictness-ledger; pnpm lint (whole repo, eslint . --no-inline-config) exit 0 — no narrowing needed; node scripts/check-partof-closing-keyword.mjs --self-test exit 0 (92 cases) and wired with real PR_BODY+PR_COMMITS_FILE exit 0 — 'its 1 commit message(s) carry no card-relation trailer' (the coordinator's RULE 2 trap: my commit carries only Co-Authored-By and Claude-Session, Fixes #16328 is body-only, no amend needed); node scripts/pm/check-clause2-carriers.mjs --pair 16846 exit 0 — 'both carriers agree'. GATE FAMILY: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 76 families from the real change set; 73 run, all green; reconciled with --ran. The 3 unrun are NOT MEASURED, not red — each refuses on its own declared prerequisite (a full workspace build this worktree lacks) and says so itself, all exiting 3 with 'PREREQUISITE NOT MET' / 'This is NOT a pass: nothing was measured': check:dual-build-cjs-loads, check:type-check-debt, @objectstack/lint check:doc-formula-expressions. CI builds the closure before those steps. Control-byte self-scan of all three edited files: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' exit 1 (no hits); check:nul-bytes exit 0. ABLATION (one-off, nothing left in tree; fix committed FIRST at b5bbc0e5b): mutate leg — reverted to bare z.object().strict(), on-disk landing proved by BOTH anchors (deleted-marker count 0, injected-anchor count 1) and by blob hash moving 786ec804... -> 70da3f23...; rebuilt; node scripts/ablation-dist-preflight.mjs packages/spec '<marker>' --absent exit 0 'marker absent from all 218 built files'; pin tests '3 failed | 22 passed (25)' — exactly the three message assertions red, every accept-set assertion green, i.e. discriminating in the expected RED direction. Restore leg — git checkout HEAD -- <path>, WHOLE-TREE git status --porcelain EMPTY, blob back to 786ec804..., rebuilt, preflight (present) exit 0, 25/25 green. Script carried trap '<restore>' EXIT INT TERM with an absolute REPO_ROOT path. POST-DISPATCH-CORRECTION RE-VERIFICATION (PR body edit only, no code change, nothing re-pushed): node scripts/check-partof-closing-keyword.mjs --self-test exit 0 (92 cases) and wired with real PR_BODY + PR_COMMITS_FILE exit 0 — 'its 1 commit message(s) carry no card-relation trailer', so the coordinator's RULE 2 trap did not catch this branch and no amend was needed; check-changeset-no-major --event <live body> exit 0 ('LEVEL AXIS: this PR declares clause-② `no`'); check-clause2-carriers --pair 16846 exit 0 ('both carriers agree'). Before sending the edit I probed the gate against a LOCAL-ONLY event payload to establish which of its two routes applied — `yes`+patch exit 1, `no`+patch exit 0 — so the seat settled the declaration on a measurement, not a guess. PR body read back after the PATCH: the bytes I sent are preserved verbatim as a prefix and the platform appended exactly one bare footer block, which is the documented edit behaviour; the literal `Clause-②: yes` token appears 0 times in the body (my first draft of the explanatory sentence quoted it and would have re-armed the gate — caught by an assertion before sending). Label read back after the size-labeler's write: needs:contract-review still present alongside four labels another actor added, which I left alone.",
      "mcp_calls": "1 — a single targeted search_issues for the out-of-scope dedup, after the REST /search/issues probe returned 403 (channel switch declared). Every other GitHub read and write went through repo-scoped REST or the zero-quota public payload channel: issue/PR reads, issue creation (#16845), PR creation, the additive label write with comparison read-back, the report comment, and the PR-body edit.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #16845: ProtectionSchema (shared/protection.zod.ts:63) is a bare z.object().strict() with no error map, so { protection: { lockk: 'system' } } on an agent refuses with zod's bare 'Unrecognized key: \"lockk\"' — no surface, no declared-key list, no rename; same underlying gap as this card, different file, reached from agent/tool/skill/position. Measured repro in the card. 承接者: the domain:spec seat — it owns packages/spec and this is the same campaign surface as #16328; the card is filed unassigned and unlabelled for triage.",
        "noted, not filed: the strictness ledger has no row for shared/protection.zod.ts (its one near-mention at line 501 is a counting bug naming kernel/metadata-protection.zod.ts, a different file), so that site looks un-enumerated by the #4001 campaign rather than deliberately accepted. Carried inside #16845 rather than as its own card. 承接者: whoever triages #16845.",
        "noted, not filed: ruling item 4 and the card's 'second known door (ActionRef / GuardRef), reported there, not fixed' are STALE. Measured on base head through dist/automation/index.mjs: both already use strictObject and already emit the named message — 'states.a.entry.0: Invalid input' with 'Unrecognized key(s) on this action reference: `parms`. Did you mean `parms` -> `params`?' nested beneath, and the GuardRef door likewise at states.a.on.GO.cond. No change was needed or made there. No card filed because the ledger's own state-machine.zod.ts row already carries the correction in its text ('The flattening is LIFTED and this clause's consequence is spent'); what is stale is the CARD's and the dispatch's restatement of it, not the repo. 承接者: the `domain:spec` seat — it owns the strictness ledger and dispatched this card carrying the stale restatement, so it is the one place a future card in this class would be framed from; the correction is recorded here and in the PR body so the next reader of #16328 does not re-derive it."
      ]
    }

    Generated by Claude Code

  8. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Contract review PASS-conditional → landed (director seat, 2026-09-08 10:5xZ)

    Reviewed-by: director seat (contract review tier claude-fable-5-1)
    Implemented-by: domain:spec seat (session session_016N6xmWt5hYm94ffVEwGH8x)


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions