feat(spec)!: publish the banned-keys rule the tracing filter arm enforces - #19137
Conversation
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…rces
The published `system/TraceSamplingConfig.json` accepted `{ dialect: 'cel' }` at
`composite[].condition` while the runtime refused it — the card's own worked
instance of a published JSON Schema WIDER than the zod it is generated from.
Teach the closed projection list a fourth named pattern, `banned-keys`, and
declare the tracing slot's rule through it. Two ledger rows retired.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Two worktrees: (1) Derived judgments
(2) Semver level
(3) Findings⛔ F1 — BLOCKING — the head is measured against a base that Measured on ⛔ F2 — BLOCKING (one word) — the changeset still declares F3 — not blocking, ⛔ must not be adopted as written. See ①.9. The correct statement is that the candidate set is time-dependent, and the releasing seat's 「re-derive FIRST」 instruction is exactly what caught a candidate that arrived later. The PR body §0's 「corrects a reading in #19005's release note」 paragraph should be amended by the seat, ⛔ not propagated into the card's record. F4 — not blocking, for the card's worklist. Three published record nodes still accept Implemented-by: VERDICT: FAIL Generated by Claude Code Generated by Claude Code |
`#19084` (`ee5812a5e3`) retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition` at the very slot this branch projects. Both intents stack: main's side of the slot is taken whole — the record-only `condition` and its retirement prescription — and its `!('dialect' in value)` predicate is declared through this branch's `bannedKeys(['dialect'])` arm. The two renamed ledger rows go, because the arm projects the site they name. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 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 29784a97f26424eefaa502a770fe21160d46e326 && git checkout 29784a97f26424eefaa502a770fe21160d46e326
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1047fe10166c943807d61218e2fde9c3ab22e502 184615ded95a9610055471de7c5214311158d787 && git checkout -B drift-repro 1047fe10166c943807d61218e2fde9c3ab22e502 && git merge --no-ff 184615ded95a9610055471de7c5214311158d787
node scripts/docs-audit/affected-docs.mjs --json 1047fe10166c943807d61218e2fde9c3ab22e502 |
`#19084` collapsed `TraceSamplingConfig.composite[].condition` to a record, so the ban lands on `condition` itself rather than on a union arm, the ledger rows are spelled `composite.element.condition`, and a CEL envelope is now refused by the runtime too. The live-seam pins and the changeset's accept-set sentence are re-derived on that tree. Also: the changeset declares `Clause-②: yes`, matching the corrected claim and the ruling; and the empty-key-list branch records the real reason it drops — `enum: []` is an invalid schema, not a vacuous rule. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ⭐ This review judges the rework head. The previously FAILed Four detached worktrees, each The four prior findings — all discharged
The contract questions
Findings
N2 — NOTED, no action. N3 — NOTED, a precision point, no action. The emitter docblock's 「 N4 — a reading disagreement, recorded rather than resolved. This review measured NOT MEASURED, declared
Instrument reachThe "0 disagreements" reading is ajv 8.20.0 in 2020-12 mode over 12 hand-chosen documents at this one slot, plus the test file's lattice. Outside that radius: any other validator's Implemented-by: VERDICT: PASS ⛔ Generated by Claude Code |
They keep no annotation: all three read `undecidable` to the detector rather than `dropped`, so they hold no ledger row and appear in no `x-dropped-refinements` — published yet unratcheted. One clause; the arm, the emitter, the ledger and the tests are untouched. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Gate cleared —
|
| tree | Test Files | Tests |
|---|---|---|
without packages/spec/dist |
496 passed | 1 skipped (497) |
14562 passed | 1 skipped (14563) |
with packages/spec/dist built |
497 passed (497) |
14564 passed (14564) |
The file is packages/spec/scripts/root-entry-type-nameability.pin.test.ts, and the mechanism is a dist-freshness branch taken at collection time — ⛔ not a platform check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one test, it.skipIf(!EXPECT_BUILT_DIST)(…), whose NAME carries the freshness state and the rerun command. Fresh ⇒ it registers two (the declaration-emit pin and its canary) and both run. OS_EXPECT_ROOT_NAMEABILITY=1 does not cause the skip; it only turns the skip into a failure for a lane that expects a built dist. Proven per file: that one file alone gives 1 skipped (1) with no dist and 1 passed (1) / 2 passed (2) after build. ⇒ 14562 + 1 skipped = 14563 and 14562 + 2 = 14564.
⇒ the reviewer's reading was of an unbuilt worktree and the dev's of a built one. Both stand. The dev's own account of its defect: the 497/14564 figure was reported without naming the precondition that produced it.
⭐ Noted, not filed, and worth carrying: that pin is honestly built — its skip states its own reason in the test name — but a skip is invisible in an aggregate totals line, which is exactly how two correct readings of this suite can differ by one file and two tests with nothing in either output saying so. ⇒ any count of this suite is only meaningful beside a statement of whether packages/spec/dist was built. Carrier: none filed; it is a reporting property of vitest totals, not a defect in the pin.
State after this comment
needs:contract-review off #18670 and off #19137. ⛔ The card keeps pm:dispatched: two of the three delivering PRs are merged and this one is still open, so de-labelling it would read as un-dispatched work and invite a second seat onto it.
Generated by Claude Code
…cord-key-preparse-guard Resolves the sole conflict in packages/spec/dropped-refinements.baseline.json (hand-edited, no gen: script — see scripts/lib/dropped-refinements.ts). The `entries` map merged cleanly with no textual conflict (main's #19137 removed two entries; this PR's site renames/additions touched a disjoint set). The `measured` header conflicted and is rewritten to exactly what `pnpm --filter @objectstack/spec gen:schema` reports on the merged tree: publishedSchemasWithDroppedRefinements 200, droppedRefinementSites 560, refinementSitesThatDidProject 357, refinementSitesWithNoJsonFormToCompare 9. The dropped-refinements gate embedded in build-schemas.ts passed with no undeclared/miscounted/repaired/vanished/unreasoned entries on the first run.
|
Correction to the first The bullet says the file is 「a generated, shrink-only ratchet that every holder regenerates, so a collision resolves by regenerating (
⇒ The honest procedure for a conflict on this file: resolve the Generated by Claude Code |
…nance instead of a liveness test; the reader accepts it and C9 keeps one red (objectstack-ai#19502) Fixes objectstack-ai#19240 Clause-②: yes `Clause-②: yes` — the claim reader's accept set widens (a cross-login `Release:` carrying provenance now retracts) and C9's judged set narrows to a bare cross-login `Claim:`; a `.claude/**` surface ⇒ Tier S, the seat lands it on its `## Contract review` PASS + `--pair` 0. This PR stays draft. ## What lands — ruling 5754797404, shape A, executed as ruled **The claim HANDOVER protocol.** A card whose claimant is unreachable (token exhausted, session ended, identity retired) is taken over by a new session in ONE comment, and the claim reader accepts that comment — no liveness heuristic anywhere: the human's word, copied with provenance, is the permission. | # | Surface | Change | Net lines | |---|---|---|---| | 1 | `scripts/pm/check-clause2-carriers.mjs` | `claimRetractions` gains the HANDOVER arm; `CLAIM_RETRACTION_RULE`, `CLAIM_HANDOVER_RULE`, `CLAIM_HANDOVER_REMEDY` rewritten; C9 keeps one red and lists refused handover attempts; self-tests both sides (1075 → 1091 cases) | +174 / −54 = **+120** (the claim's budget, exactly) | | 2 | `.claude/skills/pm-dispatch/SKILL.md` | :177 · :472 · :492 aligned in place; the nine liveness-heuristic bullets (:493–:501 at base) replaced by five handover bullets | 813 → **809** (net −4; ceiling 813, headroom 4) | | 3 | `.claude/skills/pm-dispatch/references/core-rules.md` | the two twins (:110, :111) rewritten in place | 151 → **151** (net 0) | | 4 | `.claude/agents/os-dev.md` | :94 in place: every compilable step is pushed; a handover reads the remote branch's last sha | 403 → **403** (net 0) | | 5 | `scripts/pm/check-half-states.mjs` | **untouched** — measured: `grep -n 'author !== '` → 0 hits; its `Release:` readers (H47 `latestMarkedComment` / `releaseAnswersClaim`) compare comment ORDER, never authors, so it carries no copy of the retraction rule and imports nothing from the clause-② reader | 0 | Base `32b5831`, `origin/main` merged once at `d00692f` (PR objectstack-ai#19462 had not landed at 2026-09-21T04:1xZ — SKILL.md ceiling stays 813, no region overlap). Every line ≤ 120 bytes; `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:pm-governed-prose`, `check:agent-model-declared`, `check:nul-bytes` all exit 0 on the edited files. ## 1. The reader ### The HANDOVER arm of `claimRetractions` (the one accept-set widening) A `Release:` comment by a **different login** retracts an earlier claim when, and only when: - (a) its `Release:` **line** (the first line of the body that `markerMatches(RELEASE_COMMENT_MARKER, line)` reads — the sibling's ONE reading, applied per line, so `**Release:**` and `` `Release:` `` read and `- Release:` does not) names the retracted claim's **comment id** (digit-bounded) **and** its **session id** (token-bounded; the claim's `Session:` line first, else the first `session_…` token in the claim body — a claim with none cannot be named, fail closed); - (b) the comment carries the three provenance fields of SKILL.md's 出处三件 line 「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」, each with a **non-empty** value. Missing any one piece ⇒ NOT a retraction, state unchanged. ⛔ No liveness test: the earlier claimant's later comments are irrelevant (pinned). Same-login retractions: byte-for-byte the old behaviour (no id, no session, no provenance needed). **Pinned key spellings** (`HANDOVER_PROVENANCE_KEYS = ['谁的指令', '原话', '在哪说']`, exactly the :149 vocabulary — ⛔ no fourth key, ⛔ no synonym). A field is: the key · optional decoration (`*`, `_`, backticks) · an optional parenthetical `(…)` / `(…)` · a colon (ASCII `:` or fullwidth `:` — indistinguishable on the page, pinned equal) · the value = the rest of that line up to the next key, or, when that is blank, the blockquote (`>` lines) under the key. Whitespace, `>`, decoration and separator punctuation alone are an EMPTY value (pinned per key). The two live specimens, both replayed verbatim in the self-test: - 5754797404 (inline paragraph): `**出处三件** — **谁的指令**:维护者,在本席(…)会话内的三个真实用户轮次。**在哪说**:本席会话聊天,在评论 5754717208(2026-09-21T02:44Z)之后、本条之前的连续三轮。**原话**(逐字,⛔ 未翻译、未润色):` followed by the blockquoted turns. - 5754717208 (line per field): `**出处三件**——` / `**谁的指令**:维护者(本仓 maintainer,…)。` / `**在哪说**:本会话聊天内,…。` / `**原话**(逐字,⛔ 未翻译、未润色):` followed by the blockquoted turns. ### C9 keeps exactly one red `claimHandovers` is unchanged in its walk: it reads the LIVE claims through the same `claimRetractions` map, so a handover comment (① provenance `Release:` naming the holder's claim + ③ new `Claim:` with `Branch:`/`Clause-②:` in the SAME comment) leaves one author holding ⇒ no row, no note, and the new `Claim:` is the governing claim on the `--pair` path (a comment is not later than itself, so it cannot retract its own claim — pinned). The one red left: a cross-login `Claim:` with NO `Release:` at all for the earlier claim — a real claim-jump. `CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` stays; its gating now applies to that narrowed red only (it is read at the same place as before). **Loud refusal, not silent red:** a cross-login `Release:` that TRIED to hand over a live claim (names its id or session id, or carries a provenance field) and did not is listed in the C9 sentence with its reason — `missing 在哪说`, `the comment id is not on its `Release:` line`, `the session id is not on its `Release:` line`, `the claim carries no session id to name`. A bare `Release:` by another login (a seat releasing its own claim) is not an attempt and is not listed — the first draft listed those and the `--pair 19373` row named os-steve's own two releases as "refused handovers" of os-bill's claim, which was noise; narrowed. **The remedy sentence** (`CLAIM_HANDOVER_REMEDY`) prescribes the four-item handover comment and prints SKILL.md's handover sentence **verbatim** (`CLAIM_HANDOVER_SENTENCE_LINES` = the five 认领 bullets, byte for byte), citing the 出处三件 line as its source. The old remedy words 「the HOLDER posts `Release:` … the TAKER posts nothing until then … ⛔ never a `Release:` on the holder's behalf」 are gone; ② (assignee swap) and ④ (the sha record) are stated as the seat's acts, unread by the reader. ### Self-tests (beside the existing retraction and C9 cases, ⛔ not at `selfTest()`'s tail; floor unchanged) Retraction battery: ⭐ a cross-login provenance `Release:` naming id + session is accepted — state `declared`, the handover's own `Claim:` governs, the record says "a DIFFERENT login … HANDOVER" · ⛔ missing any one field, or a key with an empty value ⇒ refused, one case per key each way, the missing key named · ⛔ id without session / session without id / both in prose under a bare `Release:` line / the three fields with no `Release:` line at all ⇒ refused · ⭐ NO liveness test: the earlier claimant commenting after the handover changes nothing · ⭐ both live specimens' spellings read, and a fullwidth colon reads as the ASCII one · ⛔ same-login `Release:` still needs nothing (arm untouched); a claim with no session id cannot be handed over · ⛔ item ④ absent still retracts (the seat's act, not the reader's gate) · the printed rule names both arms, the three keys, the source line and the absent liveness test. C9 battery: ⭐ the handover comment clears C9 (no row, no note) · the handover's `Claim:` is the governing claim on `--pair` (branch, declaration) and is not self-retracted · ⛔ the same comment missing any one field ⇒ still C9 JUDGED, the row names the refused release and the missing key · ⛔ the ONE red kept: a cross-login `Claim:` with no `Release:` at all · ⛔ a handover naming only one of two live claims leaves the other standing · the remedy is SKILL.md's handover sentence verbatim, with the 出处三件 source line and 让先到者 for a yield · each sentence line is one SKILL.md bullet by shape (≤ 120 bytes, no bullet, no issue id). ## 2. Before / after — every changed instruction line `.claude/skills/pm-dispatch/SKILL.md` | line (base → now) | before | after | |---|---|---| | :177 → :177 | `- dev 自己死了不等于维护者中止:子代理消失是正常死法,走死认领回收。` | `- dev 自己死了不等于维护者中止:子代理消失是正常死法,走接管(见认领节)。` | | :472 → :472 | `- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段。` | `- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段,接管同此。` | | :474 | `- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领。` | **unchanged, deliberately** — this line is the greppable source of `RELEASE_ACT_RULE` in `check-half-states.mjs` (outside this claim's surface); the handover reuses the act's two halves (② assignee + ① `Release:` line, by the taker), stated in the new bullets | | :492 → :492 | `- dev 侧早推分支,远程分支是在飞工作最硬的证据。` | `- dev 每个可编译小步即 push:容器随会话回收,未 push 的树救不回,可交接的只有远程分支。` | | :493–:501 → :493–:497 | the nine liveness bullets (listed in §3) | `- 认领人不可达(token 耗尽/会话结束/身份退役)⇒ 接管:一条评论四件齐,⛔ 不判死活。` / `- ① 跨账号 `Release:` 点名被撤认领的 id 与 session ID,带出处三件(谁的指令/原话/在哪说)。` / `- ② assignee 同笔换人(`--unassign 旧 --assign 新`);③ 新 `Claim:`:新 session、续用分支与远程 sha。` / `- ④ 交接记录:旧分支最后已 push 的 sha + 一句状态;读者只验①③形状,缺一件即非撤销。` / `- C9 只剩一种红:无任何 `Release:` 的跨账号 `Claim:`(真抢卡);线程上每条活认领都要点名。` | | :502 → :498 | `- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。` | unchanged (a mis-handed live seat still appends its correction) | `.claude/skills/pm-dispatch/references/core-rules.md` | line | before | after | |---|---|---| | :110 | `- 更早的他会话认领即让行并交出已诊断的一切;认领逾一天且无合并证据即疑死。` | `- 更早的他会话认领即让行并交出已诊断的一切;认领人不可达即接管,⛔ 不判死活。` | | :111 | `- dev 自死不等于维护者中止,需显式信号;回收前先救工作树,有提交的活分支 ⛔ 永不回收。` | `- dev 自死不等于维护者中止,需显式信号;接管一条评论四件齐,只救已 push 的分支。` | `.claude/agents/os-dev.md` | line | before | after | |---|---|---| | :94 | ` - 有可展示内容即 commit、push 并开 draft PR,不等验证结束;验证结果到达即写进报告。` | ` - 每个可编译小步即 commit + push;有可展示内容即开 draft PR;接管只认远程分支最后 sha。` | The dropped tail 「验证结果到达即写进报告」 survives at os-dev.md :95 (「未读到的判决写 NOT MEASURED」) and :311 (「报告在本地验证走完时交付」). ## 3. SKILL.md deletion list — each retired line's surviving home | retired line (base :493–:501) | surviving home | |---|---| | `死认领回收:认领 >~24h ⇒ 疑死;判死主腿 = 搜引用本卡的 PR、读其 merged/merged_at。` | **retired outright** — the ruling replaces liveness judgement with the human's word (:493 「⛔ 不判死活」) | | `⛔ 判死不读 closes-list;承诺分支缺席与提交扫描失效只能支持判死、永不单独确立。` | retired outright (no liveness judgement exists to bound) | | `零引用 PR ⇒ 停下发问,⛔ 不判什么都没落地。` | retired outright; the "ask first" half is the protocol itself — the handover IS the human's answer copied with provenance (:494) | | `回收前先救工作树:向任何派发 worktree 提交前先过存活/所有权检查。` | **retired outright** — the hard fact at :492: a remote container's worktree is reclaimed with the session; there is nothing to rescue | | `或对树最新 mtime 过明确年龄阈值;⛔ 不凭 GitHub 侧静默动手。` | retired outright (same reason); 「⛔ 不凭 GitHub 侧静默动手」 survives as the provenance requirement (:494) | | `过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。` | :492 (every compilable step is pushed by the dev — the WIP-rescue is moved to the writer side, before the cut) + :496 ④ (the last pushed sha in the handover record) | | `WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。` | :496 ④ 「一句状态」 — the taker records the branch's state and continues from the remote sha; "diff before continuing" is the taker's ordinary care under 「读者只验①③形状」 | | `WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。` | :496 ④ (sha + one status sentence) — no WIP commit is written by anyone but the dev itself | | `再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。` | :494 ① (the `Release:` line, now with provenance instead of a silence window) + :497 (every live claim named) — 「有带提交活分支的认领永不回收」 is retired: a pushed branch is precisely what the handover continues (:495 ③) | ## 4. PM mechanism assumptions — verified, one refuted 1. ✓ At `5e7d83c` = `32b5831` (no diff on the surface between them): `CLAIM_RETRACTION_RULE` :1731 stated "⛔ never a DIFFERENT author's line", `claimRetractions` skipped every candidate whose author differs (:1778 `candidate.author === null || candidate.author !== claim.author`), and `claimHandovers` judged cross-login claims after `CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` (:2073, `2026-09-19T03:45Z`). 2. ✓ Reproduced before the change (2026-09-21T03:5xZ, `PM_SWEEP_REPO=objectstack-ai/objectstack`): `--pair 19373` → exit 4, `✗ C9 — card objectstack-ai#17518 (delivering open PR objectstack-ai#19373) — 2 authors hold LIVE claim comments … `os-bill`'s 5646971772 at 2026-09-12T15:54:12Z is the claim that stood; `os-litant`'s 5749581295 at 2026-09-20T11:43:41Z took the card from `os-bill` (dated AFTER the effective instant 2026-09-19T03:45Z — JUDGED)`; `--pair 19335` → exit 4, `✗ C9 — card objectstack-ai#18670 (delivering open PR objectstack-ai#19335) — 3 authors … `os-litant`'s 5717305863 … stood; `os-steve`'s 5736537462 … (listed, informational); `os-bill`'s 5749165780 at 2026-09-20T10:14:08Z took the card from `os-steve` (… JUDGED)`. After the change both STILL exit 4 (same rows, the remedy now printing the four-item comment) — as predicted, until the seats post the handover comments below. 3. ✓ SKILL.md :149 reads exactly 「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」 (ASCII punctuation); the reader now reads exactly those three fields and quotes the line unbroken (`HANDOVER_PROVENANCE_SOURCE`). 4. Tier S — `node scripts/pm/check-governed-merges.mjs --pr N` is run once the PR number exists; the result is in the report. The PR stays draft. 5. **REFUTED — ruling item ② spelling.** `label-write --clear-assignees --assign NEW` is refused by the tool: `--clear-assignees cannot be combined with --assign/--unassign` (`scripts/pm/label-write.mjs` :417–:419). The one-write assignee swap is `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue N --unassign OLD_LOGIN --assign NEW_LOGIN` (`computeAssigneeTarget`: target = current − unassign + assign, one write, read back). SKILL.md :495 and the handover comments below use that spelling. ## 5. The handover comments the seats post (verbatim — ⛔ not posted by this PR, ⛔ nothing written on objectstack-ai#17518 / objectstack-ai#18670 / PR objectstack-ai#19373 / PR objectstack-ai#19335 here) Both were **simulated offline against the live threads** (the REST rows of each card plus the drafted comment appended): C9 state `null` (clear), pool = the handover comment, governing branch = the continued branch, declaration `declared` / `yes`, C8 = 0; controls — the same comment without 在哪说 ⇒ C9 judged `true`; the same comment with the session id blanked on the `Release:` line ⇒ C9 judged `true`. Placeholders in CAPITALS are the poster's to fill (its own session id / login, the UTC stamp). The 原话 / 在哪说 values copy the maintainer's words that adopted this protocol for exactly these two PRs (5754717208 § the maintainer's turns; 5754797404 「同意」, which names PR objectstack-ai#19373 and PR objectstack-ai#19335 as the two the ruling unblocks); a fresher instruction naming the card directly is a better value, if the seat has one. ### objectstack-ai#17518 (PR objectstack-ai#19373) — posted by the `domain:spec#1` seat (`os-litant`, the taker already holding claim 5749581295) ```text Release: handover of claim 5646971772 (`session_01MkQhmuuJAVDjmeWNixwDDH`, `os-bill`, branch `claude/issue-17518-assembled-body-json-schema`) and of this seat's own claim 5749581295 (`session_01LvwGppdonww4zGLWZo5rho`) · 因: the earlier claimant is a dev subagent session that ended on 2026-09-12 and cannot post its own `Release:`; the taker has delivered the whole diff on PR objectstack-ai#19373 · 去向: the `Claim:` below — same seat, same branch 谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2) 原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」 在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session) Assignee: `os-project-manager` → `os-litant`, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 17518 --unassign os-project-manager --assign os-litant` Claim: `domain:spec` seat 1 takes over objectstack-ai#17518 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC Session: `session_01LvwGppdonww4zGLWZo5rho` Branch: `claude/issue-17518-assembled-package-body-inert-json` (continued at remote sha `aac764cc36113b4e52820c1695715f000ccbe1b4`, the head of PR objectstack-ai#19373) Clause-②: yes Handover: the released claim's branch `claude/issue-17518-assembled-body-json-schema` — last pushed sha `ed8dea17bd510100320ab42dbac6ec2a78e99deb` (read from origin at 2026-09-21); status: superseded — the whole diff was re-delivered on PR objectstack-ai#19373 at `aac764c` (checks green, `## Contract review` pending), nothing from the old branch is carried. ``` Why the seat's own 5749581295 is named too: the reader would otherwise carry TWO live `Claim:` comments by `os-litant` (C8). Named on the same `Release:` line it is retracted by the same-login arm, and the fresh `Claim:` in this comment is the only one standing. If the posting session differs from `session_01LvwGppdonww4zGLWZo5rho`, the `Session:` line carries the new one. ### objectstack-ai#18670 (PR objectstack-ai#19335) — posted by the live `domain:spec` seat (POSTER_LOGIN / POSTER_SESSION_ID; the taker of record, `session_01JbZnqu8bt6YqfJsr9vaFb3`, was retired at 2026-09-20T23:34Z) ```text Release: handover of claims 5717305863 (`session_01LvwGppdonww4zGLWZo5rho`, `os-litant`, branch `claude/issue-18670-refinement-projection-census`), 5736537462 (`session_01AmH9bKvGoLjiY86Q4Z3og2`, `os-steve`, branch `claude/issue-18670-banned-keys-projection`) and 5749165780 (`session_01JbZnqu8bt6YqfJsr9vaFb3`, `os-bill`, branch `claude/issue-18670-propertynames-not-pattern-arm`) · 因: the first two claims' work is merged (PR objectstack-ai#18729, PR objectstack-ai#19137; both branches absent on origin), and the third claim's session was retired at 2026-09-20T23:34Z with its PR objectstack-ai#19335 reviewed and green — none of the three can post its own `Release:` · 去向: the `Claim:` below 谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2) 原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」 在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session) Assignee: `os-bill` → POSTER_LOGIN, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 18670 --unassign os-bill --assign POSTER_LOGIN` (a no-op when the poster IS `os-bill`; `pm:blocked` → `pm:dispatched` in that same write once the reader has landed) Claim: `domain:spec` seat takes over objectstack-ai#18670 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC Session: `POSTER_SESSION_ID` Branch: `claude/issue-18670-propertynames-not-pattern-arm` (continued at remote sha `1dfe2f40bce77270758d9b31b01dd8d46875a290`, the head of PR objectstack-ai#19335) Clause-②: yes Handover: branch `claude/issue-18670-propertynames-not-pattern-arm` — last pushed sha `1dfe2f40bce77270758d9b31b01dd8d46875a290` (read from origin at 2026-09-21); status: `## Contract review` PASS recorded at 5749728565 on this head, both carriers stripped, checks green — nothing left to build, the landing is the only step. The two older branches are absent on origin (their work merged as PR objectstack-ai#18729 / PR objectstack-ai#19137). ``` Why all three claims are named: C9 walks every LIVE claim; naming only 5749165780 would leave `os-litant` → `os-steve` → NEW as two hand-overs, the last dated after the instant — still red. The row prints exactly the ids to name (:497 「线程上每条活认领都要点名」). ## 6. Four-axis analysis ### 「No liveness test」 (ruling; 5754717208 §4's 「点名的是活认领 ⇒ 拒」 not kept) - **实际业务需求** — measured: the three specimens (objectstack-ai#17518 / PR objectstack-ai#19373; objectstack-ai#18670 / PR objectstack-ai#19335; objectui#9370) are all cases where the human already knew the claimant was gone and the machine could not: a subagent session that ended 2026-09-12, a seat session retired at 23:34Z, a retired identity. In every one the holder's silence was total, so a liveness heuristic (>24h, later comments, PR search, mtime) would have said "dead" only by luck of thresholds, and a holder that posts one late comment would have flipped a correct takeover into a refusal. The maintainer's words: 「这种情况通常都是人类口头交代的」 — the decision is already taken by a human; the reader's job is to verify the copy, not to re-decide. - **项目长远合理性** — a reader that verifies provenance is a pure function of the thread (contract-first, no workaround); a liveness heuristic is a second, contradictable oracle beside the human's word and needs its own thresholds, exceptions and reconciliation windows (which is what :493–:501 had become: nine lines of them). Long-term cost of the chosen option: a bad handover is possible on a bad instruction — but it is auditable (谁的指令 / 原话 / 在哪说 are on the card) and repairable (:498 「误伤活席位 ⇒ 令其追加式更正」). - **防 AI 写错** — the accept set is closed and mechanical: three named keys, an id + session on ONE line, fail closed on any gap, and the refusal names the missing piece. Nothing to guess; an AI seat that half-writes the comment is told which field. A liveness test would be the opposite — a tolerance rule ("probably dead") that hides a wrong takeover behind a green. - **创业阶段不扩散需求** — the ruling's own reason: 「我们系统开发了太多无用的门禁,反而在浪费时间」. Nine heuristic lines retired, five protocol lines added, no staged transition (the heuristics are gone at once — 「短期不考虑渐进」). - Recommendation held: no liveness test, per the ruling. ### 「C9 keeps one red」 (a bare cross-login `Claim:` with no `Release:` at all) - **实际业务需求** — the red exists for the measured claim-jumps (objectstack-ai#17852's two seats eight hours apart, objectstack-ai#15811's silent assignee move); those are exactly the shape left red. The two finished PRs it blocked were handovers, not jumps — they had no channel to say so; now they have one comment. - **项目长远合理性** — one state, one row, one repair (the handover comment) — no widening of C8, no second selector; the effective instant stays as history and still gates the narrowed red only. - **防 AI 写错** — deleting C9 would let any later `Claim:` silently govern (the pre-objectstack-ai#18862 SUPERSEDED exit-0 reading); keeping the red but printing the four-item comment as the remedy makes the correct act the shortest path. A refused attempt is listed with its reason instead of a bare "2 authors hold live claims". - **创业阶段不扩散需求** — no new gate, no new label, no new tool: the remedy is a comment in the spelling the protocol already has; the only added code path is the provenance read. - Alternative weighed and refused: retiring C9 entirely (「太多无用的门禁」) — refused because the ruling itself keeps 「真正的抢卡」 red, and a jump is a real, measured, silent failure. ## 7. Tests and gates (head `7a66ffe`; every exit captured before any pipe) - `node scripts/pm/check-clause2-carriers.mjs --self-test` → exit 0, **1091 cases pass** (1075 at `origin/main`, run from a temp copy in the same tree; the roster floor unchanged; both new case groups sit inside their existing batteries). - `--pair 19373` / `--pair 19335` → exit 4 before AND after (rows quoted in §4.2); after the change each row ends with the four-item remedy (`grep -c 认领人不可达` = 1 per log). - Offline simulation of the two handover comments (§5): C9 clear, governing claim = the handover, declaration `declared/yes`; controls red. - Derived union (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 48 commands, derived from the tree at `693afd7` after the `origin/main` merge and re-run at `7a66ffe`): all **48 of 48** commands exit 0 (run 2026-09-21T03:56Z–04:15Z, sequential, each exit captured before any pipe; the list reconciled with `--ran`); the slowest, `pnpm check:pm-dispatch-gates`, ran its full 1883-case battery green at this head. - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` / `@objectstack/lint` not built — NOT a finding); after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under `os-verify-lock.sh` (VERDICT command-exit 0, 203 s) it answers exit 0. - `pnpm check:pm-dispatch-gates` (845 s on this box) red once on an EARLIER draft: its governed-read census found a `readFileSync` of SKILL.md in this reader's self-test (my "same words" pin). Removed — see Deviations — and re-run green at the final head. ## Deviations (declared) 1. **The "ONE sentence" property is not a governed read.** A self-test pin that reads SKILL.md makes `check:pm-clause2-carriers` a derived family of SKILL.md and needs a `GOVERNED_READ_FLOOR` row in `scripts/pm/dispatch-gates.mjs` (outside this claim's surface; a gate-derivation change). Kept instead: `CLAIM_HANDOVER_SENTENCE_LINES` (the remedy prints the five lines verbatim) + the twin rule at review + a shape pin (each line ≤ 120 bytes, no bullet, no issue id). Open question for the seat: register the read so a SKILL.md edit that breaks the sentence reds the reader (recommended; a two-line floor row). 2. **SKILL.md ceiling not lowered** (813 → could be 809): `check-skill-line-ratchet.mjs` is outside the surface; headroom 4 is reported, the seat lowers it if wanted. 3. **:474 left byte-identical** (see §2) — the alignment the dispatch asked for is carried by the new bullets rather than by editing the line that a sibling file quotes. 4. **Ruling ② spelling corrected** (`--unassign OLD --assign NEW`), see §4.5. ## Acceptance notes (off-path; noted, not filed — ⛔ no card filed by this dev) - `scripts/pm/check-half-states.mjs` H47 leg (b) sentence still quotes 「释放回队(`Release:` 行载因)」 as "the dead-claim route" — that SKILL.md line is retired here, so the quotation is stale prose in a remedy sentence (a doc nit, not a defect; carrier: the `domain:skills` seat on its next half-states touch). - `references/platform-readings.md` :391 「处置 = 死认领回收加 worktree 抢救,⛔ 不重核前提、不升级」 names the retired route (a host-signal disposition line; outside this claim's surface — the seat's twin-rule follow-up, one line: 「处置 = 接管(认领节),⛔ 不重核前提、不升级」). - `check-clause2-carriers.mjs`'s C9 docblock still carries the objectstack-ai#18862 ruling history verbatim (「the holder posts `Release:`; the taker posts nothing until then」 as the ruling's quoted words) — kept as history, the new paragraph below it states the change; no action. - `.claude/skills/pm-dispatch/SKILL.md` :272 「维护者强制接管令 … ⛔ 不取在飞卡,由原认领者跟完」 is the seat-level forced takeover (a blanket order) and is not contradicted by a per-card handover on a named instruction; left as is. ## 维护者速读(草稿) **改了什么**:把「死认领回收」换成「接管协议」。一个 agent 做到一半没 token 了,新会话在**一条评论**里接管:① 跨账号 `Release:` 点名旧认领的评论 id 与 session ID,并带出处三件(谁的指令 / 原话 / 在哪说);② assignee 同笔换人;③ 新 `Claim:`(续用远程分支与 sha);④ 一句交接状态。认领读者(`check-clause2-carriers.mjs`)按形状接受①③,不再判死活;C9 只剩「没有任何 `Release:` 的跨账号抢卡」一种红。SKILL.md 删掉九行判死启发式,换成五行接管规则;os-dev.md 把「早推分支」提为硬要求(每个可编译小步即 push)。 **为什么改**:两张已复核完毕的成品 PR(objectstack-ai#19373、objectstack-ai#19335)今天落不了地,只因为旧认领人已经不在、没人能替它写 `Release:`;而「代执行他人指令要带出处三件」这条规矩早就在 SKILL.md 里,只是读者不读。您的原话:「这种情况通常都是人类口头交代的……我们系统开发了太多无用的门禁」。 **风险与代价(含回滚)**:风险是一条编造出处的接管评论会被读者接受——但出处三件留在卡上可审,误伤活席位按既有规则追加更正。代价是读者多一条判形状的分支(+120 行,含自测)。回滚 = revert 本 PR,一次 revert 即回到判死启发式与旧 C9。 **席位意见**:(席位填写) **你要做的**:本 PR 是受管面(`.claude/**`),由席位达档复核后落地,不需要您动手;落地后 spec 席按正文第 5 节的两条评论接管 objectstack-ai#17518 与 objectstack-ai#18670,两张 PR 即可入队。若您希望读者对「SKILL.md 与读者同句」做机械钉死(而非复核时人工核对),点一下头,席位在 `dispatch-gates.mjs` 登记一条 governed read 即可。 --- _Generated by [Claude Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…efusal through the bannedKeys arm (objectstack-ai#19346) (objectstack-ai#19538) >⚠️ **这是一次重建。** 原 PR objectstack-ai#19524 在 `os-sam` 账号停用后**不可见**;分支与提交幸存,因为它们属于仓库。 >⚠️ **本行已更正。** 它原本写的是原 PR「随账号停用一同**被销毁**(404)」。那是**错的**:实测同时为真的三条读数 —— `GET /pulls/19524` 回 **404**、按 head 过滤列 PR 可见 **0** 条、而在**同一条 head** 上 `POST /pulls` 回 **422「A pull request already exists」** ⇒ 对象仍在,仍占着「一条 head 只能有一个 open PR」的唯一性,只是对其他身份**不可见**。⛔ 卡与评论是否也只是隐藏,本席**未实测**,不作推断。本 PR 指向**救援分支**(与原分支同一个 sha `94291658`),内容是同一份工作**外加更正轮 R2**。正文主体取自原 PR 创建时的原文,逐字保留;R2 的增量另起一节写在末尾,⛔ 未混进原文。 Fixes objectstack-ai#19346 Clause-②: yes (narrowing) `ObjectSchema.fields` refuses `constructor` and `prototype` as field names. Until this PR that rule was a `.refine()` on the record's **key schema** — a `custom` check, which `z.toJSONSchema()` has no arm for — so it reached the runtime and never `packages/spec/json-schema/**`. Nine `fields.out.keyType` rows in `dropped-refinements.baseline.json` recorded exactly that, one per embedding schema. This PR rewrites the rule as a record-level `bannedKeys(['constructor', 'prototype'])` inside the existing `refuseRecordProtoKey(...)` wrapper. No arm joins the closed projection list: the ban is over a finite list of two names, which is what the existing `banned-keys` arm (objectstack-ai#19137) already expresses. ## The measurement — the card's lead, confirmed The card filed this as a lead, not a result, so the first act of the round was to measure it. Both readings are from `pnpm --filter @objectstack/spec gen:schema` in this worktree, each against a named tree. | reading | BEFORE — worktree at `48c39e00` (the branch point, pre-change) | AFTER — worktree at `90321e34` (this head) | |:---|---:|---:| | ledger entries (`publishedSchemasWithDroppedRefinements`) | 204 | **204** | | ledger sites (`droppedRefinementSites`) | 569 | **560** | | `refinementSitesThatDidProject` | 357 | **366** | | ...of which arm `banned-keys` | 2 | **11** | | ...arms `non-blank-string` / `required-one-of` / `dependent-required` | 224 / 129 / 2 | 224 / 129 / 2 | | `refinementSitesWithNoJsonFormToCompare` | 9 | 9 | Nine sites moved from `dropped` to `projected`, **zero sites were added anywhere**, and the entry count is unchanged because every one of the nine schemas keeps other rows. The generator's own diagnostic listed exactly nine `-` lines and no `+` line. The `measured` header block in the ledger was updated to match the body, which `scripts/dropped-refinements.test.ts` pins. The nine rows, by ledger entry: | entry | row deleted | |:---|:---| | `api/AssembledInstalledPackage` | `manifest.objects.element.fields.out.keyType` | | `api/GetInstalledPackageResponse` | `data.options[1].manifest.objects.element.fields.out.keyType` | | `api/InstalledPackageAtEitherStage` | `options[1].manifest.objects.element.fields.out.keyType` | | `api/ListInstalledPackagesResponse` | `data.packages.element.options[1].manifest.objects.element.fields.out.keyType` | | `api/ObjectDefinitionResponse` | `data.fields.out.keyType` | | `data/Object` | `fields.out.keyType` | | `system/ChangeSet` | `operations.element.options[3].object.fields.out.keyType` | | `system/CreateObjectOperation` | `object.fields.out.keyType` | | `system/MigrationOperation` | `options[3].object.fields.out.keyType` | ## The published file, read first-hand A ledger that reads `projected` while the published file carries nothing is the failure this card exists to prevent, so the artifact was read rather than inferred. `packages/spec/json-schema/data/Object.json`, at `.properties.fields`: ```json "propertyNames": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" }, "allOf": [ { "propertyNames": { "not": { "enum": ["constructor", "prototype"] } } } ] ``` The record's own key-TYPE rule survives — the ban is conjoined through `allOf`, never substituted — and the matching `fields.out.keyType` entry is gone from that file's `x-dropped-refinements` list. All nine carriers hold the node (`system/ChangeSet` holds two, one per union arm that embeds an object definition); a negative control, `data/Field.json`, holds none. **Validated with ajv 8 (draft 2020-12) on the generated `data/Object.json` itself, both sides.** BEFORE is the file regenerated from the branch point in this same worktree, not a reconstruction: | document at `.properties.fields` | BEFORE | AFTER | |:---|:---|:---| | `{"title":{"type":"text","label":"T"}}` | PASS | PASS | | `{"constructor":{...}}` | **PASS** — the defect | **FAIL** | | `{"prototype":{...}}` | **PASS** — the defect | **FAIL** | | CONTROL `{"constructors":{...}}` | PASS | PASS | | CONTROL `{"to_string":{...}}` | PASS | PASS | Across the published tree, **1524 of 1535 files are byte-identical** (measured by regenerating both sides in this worktree and `diff -rq`): the nine carriers above, plus the bundle `objectstack.json` and the build-input hash. ## What moves, and what does not **The runtime accept set does not move.** `bannedKeys` reads OWN properties and never `key in value`, which is what a record's key loop visits too; it is presence and never value. Every document the runtime accepted before it accepts now, and the two names it refused it still refuses. **The refusal's LOCATION moves, and a consumer reading issues by path will see it.** This is the cost of the projection and it is stated in the changeset as a FROM/TO mapping: | | before | after | |:---|:---|:---| | issue `path` | `['fields', 'the offending key']` | `['fields']` | | issue `code` | `invalid_key` | `custom` | | the reason text | nested under zod's fixed "Invalid key in record" | the issue's own `message` | The message text is unchanged and names both reserved words in full. The closed list can only publish a record-level predicate, and `.refine()` carries no per-key path, so a located-per-key refusal and a published refusal cannot both come from one rule. With a closed two-name ban, the slot is still named and both candidate keys are named in the message. **`__proto__` is untouched.** Its guard is `refuseRecordProtoKey`'s `z.preprocess` on the raw input, because zod's record parser skips that one name with an unconditional `continue` above the key schema. It holds no ledger row and gains no keyword here. This round reaches two of the three names, never three — exactly as the card measured. ## Tests `packages/spec/src/data/object.test.ts` gains three cases pinning the half that had none, and its existing behaviour pin moves with the mechanism. The projection goes through the shared `projectPublishedJsonSchema` helper on the generator's own io ladder (`data/Object` publishes as the input shape), never a local `z.toJSONSchema()`, so a pin cannot stay green while the published file goes wide. The corpus assertion is an EQUALITY between the runtime verdict and the published keywords, with near-miss controls (`constructors`, `to_string`), and the evaluator throws rather than passing vacuously when the node states no ban. **Reverse verification.** With the pre-change key-schema `.refine()` restored on disk (mutation proven by content hash `817d2dfd` to `ef293623` and by grep counts: `bannedKeys` 4 to 0, the old predicate 0 to 1), **5 of the 200 cases turn red** — the two behaviour pins and all three published-half pins. Restored from `HEAD` afterwards and the restoration proven by hash equality with the `HEAD` blob, not by an exit code. ## Verification Anchored at head `90321e34` (a merge of `origin/main` `f34dda62` into this branch; nothing upstream has touched this PR's carriers since). - `node scripts/pm/dispatch-gates.mjs --changed`: **83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN**, every family exit 0, each code captured before any pipe and reconciled through `--ran`. - `pnpm --filter @objectstack/spec typecheck` — exit 0. - `pnpm --filter @objectstack/spec test` — exit 0, **508 files / 14874 tests passed**. - `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` — exit 0, 72/72 tasks. Four gates (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`, `check:doc-formula-expressions`) first answered exit 3 PREREQUISITE NOT MET against an unbuilt closure; they were re-run green after the build rather than recorded as passes. - A changeset is included, `minor`, carrying the `Clause-②` declaration, the FROM/TO migration sentence and its ADR-0087 disposition. ## Acceptance notes Noted, not filed: - `packages/spec/dropped-refinements.baseline.json` carries a hand-maintained `measured` header block, of which `scripts/dropped-refinements.test.ts` pins two fields (`publishedSchemasWithDroppedRefinements`, `droppedRefinementSites`) against the body. The other two (`refinementSitesThatDidProject`, `refinementSitesWithNoJsonFormToCompare`) are pinned by nothing, so they can drift from the generator's own census without any gate noticing. Both were updated by hand here from this run's output. Not a defect in this PR's sense — no contract is violated and nothing is dropped — and closing it would add a ratchet row, which tonight's standing ruling forbids without the maintainer's sentence. --- ##⚠️ R2 增量 —— 原 PR 正文写于 `90321e34`,本分支现为 `94291658` 原 PR 描述的是上一个提交。此后多了一个提交,内容如下。 ### 1. CI 红已修,而根因不是我们的引用 `Lint & Repo Gates` 在 `90321e34` 上 exit 2,红在 `check-issue-citations`:`[allocated-but-absent] objectstack#17852`。 ⭐ **本地 exit 0 / CI exit 2 并不是同一个问题的两个答案**:`package.json` 里的 `check:issue-citations` 只是 `--self-test`,而 `lint.yml` 另外还跑一条**裸的**判决命令;派发令的门禁族清单只吐前者 ⇒ 那一轮**从未跑过真正的判决**。 ⛔ **没有按门禁的处方写假话。**它建议「保留号码并在散文里说明它不再解析」—— 而 objectstack-ai#17852 当时实测 **HTTP 200,解析得了**。采用的是门禁自己文档里的约定:`objectstack#17852` → `objectstack-ai#17852`(裸 `#N` 即本仓)。改后该命令 exit 0。⚠️ 底下还压着一个**真实的门禁缺陷**(`buildBoard` 把所有带限定符的引用踢出探测集,而 `classifyCitation` 又拿本仓限定符去查那块板子),它另有卡承接。 ### 2. 一处被本 PR 弄假的散文已修 `packages/spec/src/shared/record-proto-key-guard.ts` 原写着两个名字「在自己的 key grammar 里」被拒 —— 而本 PR 正是把那条拒绝从 key grammar 移到了 record 上。该段**改写而非删除**:它关于「⛔ 不要扩大本守卫自身名单」的论点仍然成立且承重。PR 文件数 4 → 5。 ### 3. changeset 补上已发布信封的迁移 实测(两侧各跑一次真 `ObjectSchema` + 真 `zodIssuesToFields`):`field` 由 `fields.constructor` → `fields`,条目数 **2 → 1**,且 `invalid_shape` 这个**已发布枚举值**在此拒绝上**不再出现**。 ### 4. 钉子的 `catch {}` 已收紧 原来裸吞异常 ⇒ 输出投影若因别的真实原因失败,**构建会红而钉子会绿**。现在捕获后除非消息含 `cannot be represented in JSON Schema` 否则重抛,并明写第三级 rung 未建模。 ### CI 状态 ——⚠️ 本节已重写 原文在此处写的是「**在 `94291658` 上 NOT MEASURED** —— 该提交刚推上,尚无任何 check-run」。那句话在写下时是真的,**现在过期了**,原样记在这里,免得读者以为它被悄悄换掉。 本席于 **2026-09-21T08:33Z** 在 `94291658` 上第一手重取花名册: ``` 39 条 check-run,全部 completed —— success 33 · skipped 6 · failure 0 · cancelled 0 七条必需上下文逐条点名,全部 success: Lint & Repo Gates 08:19:38Z ← 引用修复绿的就是这一条 TypeScript Type Check 08:22:06Z Test Core 08:23:59Z Dogfood Regression Gate 08:16:34Z Build Core 08:13:57Z Temporal Conformance (live PG + MySQL) 08:16:17Z Governed Surface Queue Guard 08:05:59Z 6 条 skipped:Console Pin Gate · Build Docs · Packed-tarball smoke (opt-in) · Check PR Size · Auto Label mergeable_state: clean ``` ⛔ `skipped` 是路径过滤器的结果,不是失败;本 head 上 **没有一条 `cancelled`**。 ### Docs Drift 回执(评论 `5757326519`)—— 已核,⛔ 不欠文档修改 正文刷新于 2026-09-21T08:35Z,以下是对该机器人评论的逐条回执。⚠️ 这次是在**当前 main `ecf56e79`** 上重取的,⛔ 不是沿用原轮次的旧读数。 它点名 `content/docs/concepts/metadata-driven.mdx`。实测该页**唯一**一处 `ObjectSchemaBase` 在第 372 行,说的是 `z.input<>` 的**编译期类型**;`constructor` / `prototype` / `__proto__` 在该页 **0 命中**(亮控:`fields` 在同页出现 9 次 ⇒ grep 够得着这一页)。本 PR 把 `.refine()` 从 key schema 搬到 record 上,**两侧的 `z.input` 都不变** ⇒ 该句不因本 PR 变假。⚠️ 一条缺口如实带上:该 drift 自己声明**有 2 个改动文件产不出锚点**(其中包括 R2 改的 `packages/spec/src/shared/record-proto-key-guard.ts`),并写明「这不是一份干净健康证明」。⇒ 那个文件的散文正确性**不在该仪器覆盖内**,靠的是 R2 自己的修正与其后的在档复核。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ces, and make the ratchet able to see it (objectstack-ai#19335) ⛔ **PARKED — 本 head 落不了地,且挡住它的不是本 PR。** 卡 objectstack-ai#18670 已转 `pm:blocked`,门禁卡是 **objectstack-ai#19240**(认领读者 `claimRetractions` 只认**同一 login** 的 `Release:`,`SKILL.md` :496 的死认领回收写不进它)。本 PR 的落地前置 ① 与 ③ 成立(达档 `## Contract review` 记录 `5749728565` 在 head `1dfe2f40bc` 上;checks 全绿);② 不成立:`check-clause2-carriers.mjs --pair 19335` = **exit 4**,唯一 ✗ 行是 C9(本卡线程上两条他席认领仍 LIVE)。完整读数、对照与本席自纠见 objectstack-ai#18670 评论 `5752999363`。⛔ 保持 draft,⛔ 不挂 auto-merge。 Part of objectstack-ai#18670 — item 2, the **fifth** arm the batch objectstack-ai#193 ruling added to the closed projection list, plus that ruling's **second acceptance item**. This body carries no closing keyword for that number on purpose: 566 dropped refinement sites remain across 205 published schemas, and whether the card closes is the seat's call rather than this PR's. Clause-②: yes **Carrier:** the published artefact `packages/spec/json-schema/data/NormalizedFilter.json`. The published JSON Schema **narrows** toward what the runtime already refuses, and no document the runtime accepts becomes refused. Director ruling `5749025303`, batch objectstack-ai#193 item 3, letter **A**, maintainer 「其他同意」 2026-09-20T09:44Z: 「A **fifth arm** joins the closed projection list: `propertyNames: { not: { pattern } }`, scoped to that one site and to the `^\$` ban, under the same one-ledger-row-at-a-time discipline as the four landed arms; the published keyword and the enforced predicate are built from a **single source** so they cannot name different things; an ablation proves the pin (the emitter removed ⇒ the rows return).」 Base `f93beea0a6`; head after merging `origin/main` (`e3b3cdd2df`) through `scripts/pm/os-regen-merge.sh`: **`1dfe2f40bc`**. --- ## 1. The measurement that decided step 1 — and it came out YES The ruling put one measurement **before** the arm: can those three `NormalizedFilter.json` nodes hold a ledger row at all? They read `undecidable`, and the thread's worry was that closing the rule would buy a narrower file with **no testable row** — the opposite trade from every arm landed so far. ⛔ It is not a grep question, and the card's own instruction says so: `packages/spec/json-schema/**` is **0 tracked files** on `origin/main` (lit control, same instrument: `packages/spec/src/data/` reads **167 tracked**), because `.gitignore:63` ignores it. Every reading below is against a tree **generated by the repo's own tooling** — `pnpm --filter @objectstack/spec build`, whose first step is `gen:schema` (`OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts`). **The answer: a row CAN be held, and the reason it was not is a defect in the detector.** The generator publishes `NormalizedFilter` through its **THIRD** projection attempt — `projectByPruningUnionBranches`, which drops the `z.date()` union branches and publishes the rest. The detector's `projectOrNull` stopped at the two strict rungs. So it was asking what a projection **nobody publishes** says, and answering `undecidable`: | node | plain output rung | plain input rung | branch-pruning rung | differential under it | |:---|:---|:---|:---|:---| | `lazy.$and.element.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | | `lazy.$or.element.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | | `lazy.$not.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | ⇒ the ruling's **first** branch applies: the detector **judges** those three nodes. The `undecidable` row shape was its fallback 「if a row cannot be held」, and that antecedent is false, so ⛔ no unread ledger field was added for an empty population. What the hole got instead is §2. ## 2. Second acceptance item — the blind spot, measured to zero and then pinned there `projectOrNull` now carries the generator's third rung and reports **which rung answered**, so a differential can never compare a pruned projection with an unpruned one (nothing observed reaches that guard; it is written down so the day it stops holding reads `undecidable` and is counted, rather than reading `projected` and vanishing). Repo-wide effect, from the generator's own census line: | | published schemas | dropped sites | projected | **undecidable** | |:---|---:|---:|---:|---:| | base `f93beea0a6` | 204 | 560 | 357 | **9** | | + the ladder rung | 205 | 569 | 357 | **0** | | + the arm (this PR) | 205 | 566 | 360 | **0** |⚠️ **The ledger GREW before it shrank, and the growth is the whole point of the item.** Seven sites became countable that no ratchet could see — `data/FieldOperators` and `data/NormalizedFilter` each gained their `$between` pair, and `data/RangeOperator` entered the ledger at all, a **published** schema that had been holding **zero** entries. Then the arm deleted three. Net: 204 entries / 560 sites → **205 / 566**. And a published site that still cannot be adjudicated now **fails the build by name**, printing the paths and the two legitimate remedies (teach the ladder a rung the generator has; or take the decision to give the ledger an `undecidable` row shape). ⛔ The hole cannot reopen in silence. ## 3. The arm, and the single source `banned-key-pattern` — 「no document may carry a key matching this pattern」 — emitted as `propertyNames` with a `not` over a `pattern`. A `$`-prefix ban is an **open** key set, so the existing `banned-keys` arm cannot express it: a finite list that merely sampled the set would be wider than the rule, which the closed list forbids by construction. **Single source, asserted rather than argued.** `bannedKeyPattern` compiles its regular expression **from** the declared pattern string, so the keyword the file publishes and the rule the runtime enforces are one string read twice. A test reads the emitted `pattern` off the published artefact and the declaration off the predicate and compares them — an emitter that re-spelled the rule, or a declaration edited without its predicate, fails there rather than drifting. **Exact, not approximate.** A JSON object's properties are exactly its own enumerable string-keyed ones, and `propertyNames` judges exactly those names. JSON Schema specifies `pattern` as an ECMA-262 regular expression evaluated as a SEARCH — unanchored, "does a match occur anywhere" — which is `RegExp.prototype.test` and nothing else. So `^\$` and the hand-written `key.startsWith('$')` it replaces name one set, pinned over a key corpus. It is presence and never value: a matching key present with a `null` value is present to both. **Scoped mechanically, which is how the ③ objection is answered.** The standing objection to a regex-shaped arm is that its over-reach cannot be read off the declaration the way a key list's can. The bound is a **second closed list**: `BannedKeyPattern` is a union of the pattern strings this package publishes, exactly one today, so a call site cannot invent a regex — there is no plain string type to pass, and widening it is the same reviewed decision that adding an arm is. The compiler refuses the second pattern; it does not arrive by a call site's choice. ⛔ No flags on the regular expression, and that is part of the equality rather than a style choice: a JSON Schema `pattern` has none to carry, and the global flag would make `test` stateful through `lastIndex`, so a key's verdict would depend on which keys were judged before it. Pinned both ways. ⛔ The predicate reads OWN enumerable keys and never the `in` operator — pinned with a name planted on the prototype, where the two readings actually come apart. ## 4. The card's own class, before and after — measured with a real validator ajv 8 (draft 2020-12) compiled against the **generated** `data/NormalizedFilter.json` on each side: | document | ajv BEFORE | ajv AFTER | |:---|:---|:---| | `{}` | true | true | | `{"$and":[{"amount":{"$eq":1}}]}` | true | true | | `{"$and":[]}` | true | true | | `{"$and":[{"$and":[]}]}` | true | true | | `{"$or":[{}]}` | true | true | | `{"$not":{}}` | true | true | | `{"$not":{"amount":{"$eq":1}}}` | true | true | | `{"$and":[{"$bogus":{"$eq":1}}]}` | **true** | **false** | | `{"$or":[{"$bogus":{"$eq":1}}]}` | **true** | **false** | | `{"$not":{"$bogus":{"$eq":1}}}` | **true** | **false** | The three that move are refused by the runtime, which names the rule: 「a field condition's keys are field names, never `$`-prefixed operators」. ⇒ the validator stops answering PASS on metadata the platform refuses, and **nothing the runtime accepts became refused** — the empty combinators and the nested group members are the direction that would have broken had the ban landed on the union instead of on the field-condition branch, and they are pinned. All three published nodes now carry the rule, conjoined and never substituted (a record states `propertyNames: { type: 'string' }` of its own, and replacing it would trade a key-TYPE rule for a key-NAME rule — a narrowing bought with a widening): ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "...": "the operator map" }, "allOf": [ { "propertyNames": { "not": { "pattern": "^\\$" } } } ] } ``` ## 5. Blast radius — the whole published tree The six source files were reverted to the base, the generator re-run, and the two trees compared byte for byte. **Revert leg proven on disk:** each path's blob hash equalled its base blob before anything ran. **Restore leg proven by bytes:** `git diff HEAD` printed **0 bytes**, `git status --porcelain` printed nothing, and each path's blob hash equalled its HEAD blob. | reading | value | |:---|:---| | files common to both trees | 1535 | | **byte-identical** | **1530** | | moved | **5** | The five, by name: `data/NormalizedFilter.json` (gains the ban at three nodes; gains the two `$between` annotation rows the ladder made visible), `data/FieldOperators.json` and `data/RangeOperator.json` (**annotation only** — they gain `x-dropped-refinements` rows, and `x-` keywords are ignored by every validator, so the set of documents they accept is unchanged), `objectstack.json` (the bundle; its 29 differing leaf paths sit under exactly those three definitions and nowhere else), and `.build-input-hash-schema`. ⭐ **`openapi.json` measured separately and with the right instrument.** `gen:schema` never writes it, so comparing it inside the sweep above would have read two copies of the same stale file and reported a false identical. `gen:openapi` was run on both trees: sha256 `34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on **both** sides — this arm reaches no schema that surface publishes. ## 6. Ablation — the pin can fail, and the rows do return `scripts/ablation-replace.mjs` replaced the one line dispatching the arm, with the mutation verified against the disk: anchor **1 → 0**, marker **0 → 1**, blob `4c5881bf5d1f` → `92da85bc6406`. ⭐ Resolution stated, because a false green here points the wrong way: every consumer reaches this module by a **relative** specifier, which resolves to source and never through the package `exports` to `dist`. There is no built artefact between the mutation and the verdict, so no dist preflight applies. | leg | result | |:---|:---| | `refinement-projection.test.ts` | **exit 1** — 12 failed / 66 passed, the single-source pin and the live seam among them | | `gen:schema` | **exit 1** — naming all three rows returning by name: `lazy.$and.element.options[0]`, `lazy.$not.options[0]`, `lazy.$or.element.options[0]` | | **restore** | blob back to `4c5881bf5d1f` **==** HEAD, `git diff HEAD` **0 bytes**, anchor back to 1 and marker back to 0 | The second leg is the ruling's own requirement: 「the emitter removed ⇒ the rows return」. They do — and they exist to return **only because** §2 made those nodes countable first. Regenerated afterwards, `data/NormalizedFilter.json` came back to sha256 `80041a0b…`, byte-identical to the pre-ablation artefact. ## 7. Verification — real exit codes, each captured before any pipe | check | exit | |:---|:---| | `pnpm --filter @objectstack/spec build` | **0** | | `pnpm --filter @objectstack/spec typecheck` | **0** | | `pnpm --filter @objectstack/spec test` | **0** — 500 test files / 14663 tests, all passed, dist built | | `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced | | `pnpm --filter @objectstack/spec gen:openapi` | **0** — byte-identical to base | | `pnpm --filter @objectstack/spec check:generated` | **0** — 16 of 16 generated artefacts up to date | | `pnpm lint` | **0** — the whole repository, `eslint . --no-inline-config`, not a narrowed subset | | derived gate families, reconciled by `scripts/pm/dispatch-gates.mjs --ran` | **86 derived / 82 exit 0 / 4 NOT MEASURED / 0 UNRUN** | The four NOT MEASURED each exit **3** — `PREREQUISITE NOT MET`, a code that is explicitly neither pass nor failure — because each needs a whole-repo build closure that CI produces: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`. ⛔ Declared, not skipped. ⭐ **`api-surface-declarations/` moved, and the movement is order-only — but it IS mine.** `check:api-surface` (the name-level gate) stays green with no diff at all. The declaration-text artefact did move, and rather than assume, it was tested: with this branch's six source files reverted to the base and the package rebuilt, `check:api-surface-declarations` exits **0** — so the movement belongs here. Characterised by bytes: 10 changed lines, 9 of them a whole-line multiset identity (two enum members swapping places), and the tenth a union whose quoted tokens are the same set, the same count, and whose text is identical once the tokens are masked. ⇒ **no declaration added, removed, or changed in meaning.** Regenerated and committed as its own commit. ## 8. Merge hygiene `origin/main` was merged in through `scripts/pm/os-regen-merge.sh` — ⛔ never rebased, ⛔ never force-pushed. That path was taken because `git check-attr merge` reads **`os-regen`** on `packages/spec/api-surface-declarations/api.txt` and `system.txt`, per file rather than by counting `.gitattributes` rows. After the merge the implementation body was re-asserted by name (`bannedKeyPattern`, `OPERATOR_PREFIX_KEY_PATTERN`, `BannedKeyPattern`, `emitBannedKeyPattern`, `conjoinPropertyNames`, `undecidableEntries`), the whole chain was regenerated, and `check:generated` reported 16 of 16 current with **no** regeneration diff. ## Acceptance notes -⚠️ **A dispatch instruction that the repository contradicts, named rather than quietly resolved.** The dispatch said to regenerate `packages/spec/dropped-refinements.baseline.json` 「with the repo's tooling; never hand-edit it」. There is no such tooling: the ledger has no `gen:` script by design, `build-schemas.ts` calls it 「a committed, hand-edited ledger」 in its own refusal text, and the module docblock argues the point at length — a generator would let a new gap be admitted by running a command instead of by a decision. The operative half of the ruling — 「⛔ do not serialise on it」 — was followed: this PR did not wait on objectstack-ai#19147. Every ledger edit here is the **corrected entry the gate itself printed**, pasted verbatim, which is the closest thing to tooling the artefact has. - **Noted, not filed — the sibling changeset in this same release now contradicts the tree.** `.changeset/18670-project-banned-keys.md` records that the `$`-prefix sites 「stay unprojected … carry NO annotation and hold NO ledger row: published yet unratcheted」. True of its own tree, false of this one. ⛔ Not rewritten — a landed record of what that PR shipped — so this PR's changeset states the supersession instead, and the two read coherently as one CHANGELOG. Carrier: none needed; both entries publish together. - **Noted, not filed — and this PR IS the carrier the previous one named.** objectstack-ai#19137 named 「the next PR that edits `packages/spec/scripts/build-schemas.ts`」 as carrier for a stale mention of the retired `api-surface-signatures.json`. This PR does edit that file, so it inherits the hand-off, and it is being declined deliberately: the line is a documentation nit in a comment, not one of the three filing classes, and it is not this ruling's defect class. It survives at `packages/spec/scripts/build-schemas.ts:874`. Carrier: the next PR that edits that file for a reason of its own. - **`dropped-refinements.baseline.json` is a shared hot file** held by objectstack-ai#19147. Not serialised on, per the ruling; collisions resolve by regenerating through `scripts/pm/os-regen-merge.sh`, ⛔ never by hand-editing conflict markers. - The arm list's own roster pin and the new pattern-set pin are both asserted as exact equalities, so a sixth arm — or a second pattern — updates a reviewed line in a diff rather than widening the narrowing quietly. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #18670 — item 2, the fourth of the ruling's four named arms: banned keys. This body carries no closing keyword for that number on purpose: measured banned-key sites are still unprojected (§6), and whether the card closes is the seat's call rather than this PR's.
Clause-②: yes
Carrier: the published artefacts
packages/spec/json-schema/system/TraceSamplingConfig.jsonandsystem/TracingConfig.json. The published JSON Schema narrows toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ Theyesstands on the ruling's own axis — a published artefact narrows — and the at-tier review measured that it stands there independently of the C5 tell:check:api-surfaceandcheck:api-surface-declarationsboth exit 0 with no diff at all, becausesrc/shared/refinement-projection.tsis re-exported by no entry barrel and is not a.zod.ts, so it is not infiles[]. The C5 widening tell is real — the as-const rosterPROJECTABLE_REFINEMENT_PATTERNSgainsbanned-keysand an exportedbannedKeys()appears beside it — but that roster is an internalexport const, not the package's public entry surface. ⛔ Theyesdoes not depend on it either way.Director ruling batch #154 item 3, letter C (maintainer 「同意」, 2026-09-18T04:56Z): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as
x-dropped-refinements」.⛔ This body was REPLACED WHOLESALE by the seat, and last refreshed at 2026-09-19T00:07Z for head
184615ded9The delivering dev writes a PR body once, at creation, and ⛔ does not patch it; a later correction is named in its report for the seat to write. That convention met a case it does not cover: the tree the first body described no longer exists. PR #19084 (
ee5812a5e3) retired the CEL expression arm at this very slot before this branch mergedorigin/main, soconditionis now a plain record and not a union — and the union framing ran through §0, §1, §3 and §4 alike. A patch of some sections would have left the artefact self-contradictory about the only tree it can land on, so the seat replaced it rather than appending a third correction block.Five things were stale, and each is now stated for head
384d27ac18:anyOf[0]$-ban reaches ONE published node77 derived / 74 exit 0 / 3 exit 3Clause-②disagreement between the claim and the rulingyeson both carriers, and the claim comment carries the correction⛔ Item 4 and item 5 were the seat's errors, not the dev's: the dev copied the claim line verbatim as the dual carrier requires, and only the seat writes claims and labels. Item 2 was the dev's, and the dev retracted it itself on measurement. The retracted text is preserved at the end of this body as HISTORY rather than deleted.
0. The pre-condition the releasing seat set — and the answer
The release of #19005 set a hard gate on whoever took this card next:
Re-derived. The set is NOT empty, and its clean member is the card's own worked instance.
⭐ The candidate set is TIME-DEPENDENT, and that is the whole reason the pre-condition was worth setting. #19005's census recorded zero clean candidates, and that was a correct reading of its own tree — the
dialectpredicate at this slot did not exist yet; it arrived with #18638, hours later. The instruction to re-derive the set FIRST is exactly what caught a candidate that landed after the last census, and it is the reason this card had work in it at all. ⛔ No sibling release was wrong; an earlier draft of this body said one was, and that claim is withdrawn.Instrument: a TypeScript-AST scan of every
.refine/.superRefine/.checkcall expression underpackages/spec/src/**/*.ts(non-test), dumping each predicate's argument text — 114 custom-check call sites across 1008 source files (superRefine69,refine44,check1; 3.overwritecalls excluded, they are not custom checks). LIT CONTROL: 6 of those call sites spell an already-declared arm (requiredOneOf×2,NON_BLANK_STRING×3,dependentRequired×1), so the scan does see the population it is supposed to see.Radius, by form: source text of tracked files. A known target outside it: whether a given call site's node is a ledger row — the ledger's sites are computed at run time by the detector against
packages/spec/json-schema/**, which is gitignored and returns 0 tracked entries. That is precisely why the earlier shape-only reading on this card was recorded as "not a reading". So the population question was answered with the instrument that can see it:collectDroppedRefinementsrun over the live schemas, plus the generator's own census.Result — 4 of the 114 predicates judge KEYS at all, and they split three ways:
src/system/tracing.zod.ts(samplingcondition)!('dialect' in value)src/data/filter.zod.ts:1916!Object.keys(condition).some((key) => key.startsWith('$'))undecidable, 0 ledger rows, yet 3 published nodes.src/ui/action.zod.ts:1844Object.keys(hints).every((k) => known.has(k))data.params— not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes.src/data/driver/common.zod.ts:5371. The arm
banned-keys— "no document may carry any of these keys" — emitted aspropertyNameswith anotover the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced:src/shared/refinement-projection.tsdeclares the arm and builds the predicate from that declaration,scripts/lib/refinement-projection.tsemits it, and both halves still reachz.toJSONSchemathrough the one sharedprojectPublishedJsonSchemacall.The slot is a record, not a union. #19084 retired the CEL expression arm of
TraceSamplingConfigSchema.composite[].condition, so the node is now a singlez.record(z.string(), z.unknown())carrying the retirement's own refusal hook and itsabort: truemessage. The anonymous.refine((value) => !('dialect' in value))that guarded it is replaced by the declaredbannedKeys(['dialect'])— the retirement's prescription, error hook and message are taken frommainwhole, and only the predicate is declared. ⛔ The retirement's behaviour is unchanged by this PR; what changes is that the rule now has a published form.Exact, not approximate. A JSON object's properties are exactly its own enumerable string-keyed ones, and
propertyNamesjudges exactly those names — so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value: a banned key present with anullvalue is present on both sides.⛔ The predicate reads OWN properties and never
key in value.inwalks the prototype chain, so a ban on a nameObject.prototypecarries —toString,constructor,valueOf— would refuse{}itself whilepropertyNamesaccepts it ('toString' in JSON.parse('{}')istrue). That is a disagreement about a JSON document, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spellsObject.prototype.hasOwnProperty.call(value, key)for that reason.The emitted keywords are conjoined, never substituted. The node is a record and already states
propertyNames: { type: 'string' }of its own; replacing it would trade a key-TYPE rule for a key-NAME rule, which is a narrowing paid for with a widening. The ban goes underallOf, the same disciplineemitNonBlankStringfollows for an existingpattern, and the measuredformat-type.tshazard is untouched — a top-levelanyOfis still never written, and the reference renderer reads neitherallOfnorpropertyNames.An empty key list emits nothing, and for a stronger reason than "it would ban nothing":
enumis specified as a non-empty array, so{ not: { enum: [] } }is an invalid schema rather than a vacuous one — ajv refuses it with "enum must have non-empty array", which would take the whole published file down instead of leaving a keyword nobody reads. The declaring signature takes a non-empty tuple, so the guard is belt-and-braces at a seam two files apart.2. The rows retired, by name
packages/spec/dropped-refinements.baseline.json, 202 entries / 553 sites → 200 / 551:system/TraceSamplingConfigsites: ["composite.element.condition"]system/TracingConfigsites: ["sampling.composite.element.condition"]…condition.options[0], because the node was then a union arm; #19084 renamed them by making the node a record, and the rows deleted here are the renamed ones. 2 rows deleted, 0 shrunk, 2 sites closed, 0 sites added anywhere; the ledger diff is deletions only.Generator census after: 551 dropped across 200 published schemas, 357 projected — 224
non-blank-string, 129required-one-of, 2dependent-required, 2banned-keys— 9 undecidable.The
measuredblock is re-snapshotted from this run:refinementSitesThatDidProject367 → 357 andrefinementSitesWithNoJsonFormToCompare3 → 9. ⛔ This PR moved neither number. The projected total fell because #19084 retired expression arms elsewhere in the tree; the main-tip block was already stale on its own tree. Re-snapshotting is what this PR owes for editing the file at all, and it is not a reading this arm produced.3. The card's own worked instance, before and after
The issue body cites
system/TraceSamplingConfig.json:— "That accepts
{dialect:'cel'}— which the runtime refuses." The union wrapper is gone with #19084; the same record is now the node itself, and on the merge base it publishes unchanged in substance:{ "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} }After:
{ "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {}, "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ] }and⚠️ that is #19084's retirement, not this PR, and this PR neither revives the expression arm nor extends the refusal;
x-dropped-refinementsis gone from both artefacts. Measured at the slot:{ "dialect": "cel" }is refused by the runtime and now by the file;{ "dialect": "cel", "source": "record.amount > 10" }is refused by both sides —{ "amount": { "$gt": 10 } }is accepted by both;{}and{ "service": "api" }are accepted by both;{ "dialect": null }is refused by both.4. Blast radius, measured on the whole published tree
Re-measured on the new base (
aadea24b89): the three edited source files were reverted toorigin/main, the generator re-run, and the two trees compared byte for byte.system/TraceSamplingConfig.json,system/TracingConfig.jsonThe diff of each moved file is exactly: gain the
allOfban, lose the matchingx-dropped-refinementsrow. Nothing else in either file changes. (The revert leg was proven on disk — each path's blob hash equalled itsorigin/mainblob — and the restore leg bygit diff HEADprinting nothing.)openapi.jsonwas measured separately and by the right instrument this time:gen:schemanever writes it, so the first comparison read two missing files and reported a false MOVED. Runninggen:openapion both trees gives a byte-identical file, sha25634b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaaon both sides.5. Ablation — the pins can fail, both halves
Re-run on the new head; the earlier ablation measured a tree that no longer exists.
scripts/ablation-replace.mjsreplaced the one line dispatching the arm (emitBannedKeys(jsonSchema, declared.keys);) inscripts/lib/refinement-projection.ts, with the mutation verified against the disk (anchor 1 → 0, blob0a21fb6f9b66→6e55fe06cef5):refinement-projection.test.tsgen:schemacomposite.element.condition,sampling.composite.element.condition), each record/abortinggit diff HEADemptyThe second leg is the one that matters for the ledger's whole purpose: with the emitter gone, the two deleted rows come back as undeclared gaps. The row deletion is load-bearing, not decorative.
6. What is left, measured rather than estimated
src/data/filter.zod.ts:1916bans every key starting with$on a normalized field condition, and it reaches THREE published record nodes inpackages/spec/json-schema/data/NormalizedFilter.json:properties.$and.items.anyOf[0]properties.$or.items.anyOf[0]properties.$not.anyOf[0]Measured on this head: all three publish as a bare object with
propertyNames: { type: 'string' }and no ban, none of them appears in that file'sx-dropped-refinements, and the file PASSes a document the runtime refuses — the runtime's answer for that document names the rule: 「a field condition's keys are field names, never$-prefixed operators」.All three read
undecidableto the detector, becauseFieldOperatorsSchemacarriesz.date()members that throw in both io directions — so they hold 0 ledger rows while the branch-pruning path publishes them anyway. ⭐ Published yet undecidable is a ratchet blind spot in its own right, and it deserves a line of its own on the card's worklist, separate from the fifth arm it would take to close.Closing the rule itself is a second public-contract decision, not a refactor of this one: an open key set cannot be spelled as a finite
keys:list — a list that merely sampled the open set would be WIDER than the rule, which the closed list forbids by construction. It needs a pattern-shaped declaration (propertyNames: { not: { pattern: "^\\$" } }). ⇒ closing it is a real narrowing with no ledger row to make it testable, which is the opposite trade from this arm.⭐ The changeset now says the same thing. An earlier revision of it claimed these sites 「stay unprojected and keep their annotation」, which is false on the tree; the at-tier review caught the disagreement between the two carriers and the clause was corrected before landing.
src/ui/action.zod.ts:1844stays dropped and annotated, correctly: its allowed key set is computed from the siblingdata.params, and JSON Schema cannot express "property names drawn from another array field's values".7. Verification
Run on head
184615ded9, each exit code captured before any pipe.⭐ The at-tier contract review returned PASS, on head
384d27ac18(record: PR comment5737573936). The branch has moved once since, by exactly one prose clause in one changeset file (git diff --stat 384d27ac18 184615ded9→1 file changed, 1 insertion(+), 1 deletion(-)), so the contract surface the review judged is byte-unchanged andneeds:contract-reviewis cleared on both carriers (record:5737671517).packages/spec/distwas built — the two readings below are both correct, of different trees:packages/spec/dist496 passed | 1 skipped (497)14562 passed | 1 skipped (14563)packages/spec/distbuilt497 passed (497)14564 passed (14564)The discriminator is
packages/spec/scripts/root-entry-type-nameability.pin.test.ts, which takes a dist-freshness branch at collection time — ⛔ not a platform check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one test,it.skipIf(!EXPECT_BUILT_DIST)(…), whose NAME carries the freshness state and the rerun command. Fresh ⇒ it registers two (the declaration-emit pin and its canary).OS_EXPECT_ROOT_NAMEABILITY=1does not cause the skip; it only turns the skip into a failure for a lane that expects a built dist. ⇒14562 + 1 skipped = 14563,14562 + 2 = 14564.pnpm --filter @objectstack/spec testdistwas builtpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec gen:schemapnpm --filter @objectstack/spec gen:openapiopenapi.jsonbyte-identical to basepnpm --filter @objectstack/spec check:generatedscripts/pm/dispatch-gates.mjs --ran)The four NOT MEASURED are
check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— each exits 3 (PREREQUISITE NOT MET, a code that is explicitly neither pass nor failure) because each needs a whole-repo build closure that CI's Build Core / lint.yml produces. They are declared, not skipped. ⭐ The earlier count of 77/74/3 was taken before the changeset file entered the change set; the five families the changeset brings in (check-empty-changeset×2,release-rehearsal-clone --self-test,check:objectui-changeset,check:pm-changeset-deadline-census) all exit 0. Under-reporting a NOT MEASURED as "tested" is the exact inverse of this lane's reading discipline, and the PR body is where a reviewer reads the coverage claim.packages/spechas no workspace dependencies, so the dependency-closure build is empty; the public entry surface is unchanged (src/shared/refinement-projection.tsis not re-exported fromsrc/shared/index.ts, which is whycheck:api-surfaceandcheck:api-surface-declarationsboth stay green with no artefact regeneration).Acceptance notes
dropped-refinements.baseline.jsonis a shared hot file. It is a generated, shrink-only ratchet that every holder regenerates, so a collision resolves by regenerating (scripts/pm/os-regen-merge.sh), ⛔ never by hand-editing conflict markers. This PR did not wait on it.origin/mainwas merged into the branch (mergef66984fb1a); ⛔ no history on this branch was rewritten.scripts/build-schemas.ts:830still carries a stale mention of the retiredapi-surface-signatures.json. feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005's release named the next editor of that file as its carrier. This PR does not editbuild-schemas.tsat all, so it does not become that carrier. Carrier: the next PR that editspackages/spec/scripts/build-schemas.ts.build-openapi.tsbranch still has no live sample. Another seat measured that all nine schemas it projects readdeclaredProjectable=0. This arm's two sites are not among them, andopenapi.jsonis byte-identical across this change. Carrier: whoever next teaches an arm a site that OpenAPI publishes.packages/spec/dropped-refinements.baseline.jsonyielded no anchor, so pages documenting that file are NOT COVERED by that run — explicitly not a clean bill of health. Read and carried here rather than left unanswered: the ledger is a machine-maintained ratchet with no hand-written reference page to drift against, and this PR's edit to it is two row deletions plus a re-snapshot of its ownmeasuredblock.HISTORY — what this body used to say, kept rather than deleted
⛔ Three claims were carried by earlier revisions of this body and are withdrawn. They are recorded here because a correction that deletes its own subject is not a correction.
dialectpredicate was introduced by feat(spec)!: every engine-evaluated expression slot requires a non-blanksource#18638, after both5e5ec9fa42(feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) and72c1640504(feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005). At those commits the slot carried zero custom checks and no ledger row, so both zeros were correct readings of their own trees. The correct statement is §0's: the candidate set is time-dependent.Clause-②: no— WITHDRAWN. The claim comment declaredno, which is wrong on the ruling's own axis: a published artefact narrows.check-clause2-carriersseparately judged C5 广化线索 atsrc/shared/refinement-projection.ts(the as-constPROJECTABLE_REFINEMENT_PATTERNSroster gainingbanned-keys), and the precedent is exact:required-one-of(feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) anddependent-required(feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005) both shippedyesfor additions to that same array.yes, and all three carriers — claim, body, changeset — agree.77 derived / 74 exit 0 / 3 exit 3— WITHDRAWN, superseded by §7's82 / 78 / 4.Attribution (prose, because the edit side of a PR-body write always appends its own footer): this body was written by the
domain:specPM seat in sessionsession_01AmH9bKvGoLjiY86Q4Z3og2; the change itself was implemented by the dispatched dev on branchclaude/issue-18670-banned-keys-projection.Generated by Claude Code