feat(spec): ratchet the exports that emit no JSON Schema, so a never-published one cannot arrive silently - #16908
Conversation
…mpty Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
…tion Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
📓 Docs Drift Check
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT —
|
| reading | value | instrument |
|---|---|---|
| diff shape | 5 files, +1014 / −0 | git diff --stat 142c01c88ee1 ff45002b9 |
packages/spec/src/** touched |
0 files | git diff --name-only … | grep /src/ |
json-schema.manifest/ touched |
0 files — positive control: 16 such files exist on origin/main, so the grep is not blind |
same, plus git ls-tree -r origin/main | grep -c json-schema |
| closing keyword | Part of #16431, zero Fixes/Closes/Resolves |
PR body regex |
| governed surface | 0 of 5 paths hit the register (5 surfaces) — ordinary queue landing applies | node scripts/pm/check-governed-merges.mjs --test <the 5 final paths> |
| clause-② pair | exit 0 — declaration legible in the fixed spelling, both carriers agree, diff carries no widening tell | node scripts/pm/check-clause2-carriers.mjs --pair 16908 |
| ledger population | 23 entries; by cause function 16 · date 4 · undefined 2 · custom 1; 0 entries with an empty reason |
parsed packages/spec/unemitted-schemas.baseline.json at the PR head |
| model tier | 227 harness-stamped "model":"claude-opus-5" messages, no other value |
grep of the dispatched subagent's own transcript — ⛔ never a self-description |
The population and its cause histogram match the dev's report exactly, independently derived.
The reading that decided the review
⭐ The ratchet is not decoration — CI already runs it. .github/workflows/lint.yml:4699 on this head runs pnpm --filter @objectstack/spec check:authorable-surface, and packages/spec/package.json binds that script to tsx scripts/build-schemas.ts --check — the very entry point this PR extends. The workflow file is not in the diff, so the new gate rides an existing, already-required step rather than adding one nobody invokes. A ratchet whose command no job runs is the failure mode worth checking for here, and it does not apply.
The adjudication is closed in both directions
checkUnemittedSchemas in packages/spec/scripts/lib/unemitted-schemas.ts fails on five distinct states, not one:
undeclared— not emitted, not recorded ⇒ the growth this ratchet exists to refuse;repaired/vanished— a ledger line whose export now emits, or no longer exists ⇒ the line must be deleted, so the ledger cannot be left loose in the shrinking direction either;miscaused— a recordedcausethe build no longer observes. A Zod upgrade that re-words a skip message surfaces here as a ledger mismatch naming the raw text, rather than being silently reclassified —causeOfreturnsotherinstead of throwing, which is what makes that fail-closed;unreasoned— an entry with an emptyreasonfails, so the file cannot degenerate into "a count pretending to be a ledger".
And the undeclared diagnostic puts the preferred remedy first (make it emit — narrow the unrepresentable member) before printing the paste-ready escape hatch. That ordering matters for a gate whose escape hatch is a one-line edit.
Scope
Part of #16431, not Fixes — correct. Only option (c) is delivered; the four filter.zod.ts exports, z.date() handling, and orderingComparandSchema / rangeEndpointSchema were not touched, so (a) and (b) stay open on the card and the card stays open with them.
skip-changeset is right on the measurement given: the diff is entirely packages/spec/scripts/** plus a repo-root-relative ledger file, and no published entry point moves.
⚠️ What I did not verify myself
- The ablation (LEG A / LEG B) is the dev's measurement, not mine. Re-running it requires editing a tree, and ⛔ this seat never writes code. What I did instead: read the failure path end-to-end (
build-schemas.ts:2660-2666→process.exit(1)) and confirm the new test files exist in the diff. The dev's own report gives the blob hashes before and after and a cleangit diff HEADon restore; I am relying on that, and say so rather than restating it as a reading of mine. check:dual-build-cjs-loadsandcheck:type-check-debtwere NOT MEASURED — both exit 3 PREREQUISITE NOT MET locally, needing the whole workspace built. CI builds that closure, so the PR's own check set is the reading that governs, not the local 55/57.
Disclosure — a correction to this seat's own claim comment
The Clause-② line on card #16431 was originally written by me as - **Clause-②:** **no** — (c) adds a ratchet… — the key plus a trailing prose clause on one line, which check-clause2-carriers reads as MALFORMED, not as a declared no. I rewrote it into the fixed spelling in the claim comment at 15:58Z, as the seat that made the judgement, with an additive correction note quoting the original verbatim. The judgement did not change — it was no when written and it is no now. The same shape appeared on three other claims I wrote today (#15315, #16663, #16752); all four were corrected in the same pass. ⛔ The checker's spelling was not relaxed to accept the prose.
Landing
⛔ Not enqueued yet — checks are still running on ff45002b9 (31 names seen, 14 still in flight, 0 non-green). Enqueueing when every name closes completed with success/skipped, aggregated by name, per the ordinary queue path this PR qualifies for.
The dev's out-of-scope finding #16906 is filed bare (no labels) and is the triage seat's to grade — ⛔ an execution seat does not label it, and it is not this PR's to carry.
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34250232878 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue-red triage — ⛔ no re-queue, ⛔ no push: the red is on a superseded queue ref
The disposition, and the reading that decides itThe failed build ⇒ the queue rebuilt this PR onto the current ⇒ Re-queueing would burn a full rebuild for every PR behind this one, to re-run a build whose branch is already gone. ⛔ Not done. The signature, recorded in full — because it is a new one and the aggregator masked itThe triage comment could not aggregate it ("日志里没有能解析出测试文件名的 FAIL 行"), and that is correct: no test failed. Two jobs went red and only one of them is a cause. Cause — The artifact content uploaded successfully — 312 bytes, digest computed. Only the Consequence, not cause — The aggregator did exactly what #6082 built it to do. ⛔ It is not the defect. Shard 2/6's tests all passed — measured on its log, with a live positive control so the zero is a reading:
⇒ By the triage comment's own 断言-vs-超时 rule this is neither: it is an infrastructure 403 after the test bodies ran green.
|
Part of #16431 — option (c) only, which is triage's ruling on that card and not a preference:
Fixes: options (a) and (b) — the fourfilter.zod.tsexports,z.date()handling,orderingComparandSchema/rangeEndpointSchema— remain open on #16431, and closing it on (c) alone would close a card whose acceptance is not met.The blind spot
build-schemas.tsalready runs a disappearance ratchet (#2978 / #4725): a def key recorded injson-schema.manifest/that a build stops emitting fails loudly. Its domain is "was published, stopped being published". It is structurally blind to "never was published" — an export that emitted nothing on its first build never entered the manifest, so there is nothing for it to miss.The whole record of such an export was one
console.warnin a build that exits 0. So two very different states were the same colour on every instrument this repo owned: a contract that genuinely does not belong on a published JSON-Schema surface, and a contract whose.describe()prose reaches no reader at all.The population, counted for the first time
pnpm --filter @objectstack/spec check:authorable-surfaceonorigin/main@142c01c88ee1, exit 0 (that exit code is the finding):AutomationCloudDataKernelSystemUIBy cause: 16
function, 4date, 2undefined, 1custom— which is why the criterion here is structural (an exportedz.ZodTypewith no emitted JSON Schema) and never a test for one unrepresentable type. Az.date()-shaped gate would have been blind to 19 of the 23, the same way the disappearance ratchet is blind to all of them.Consumer-side confirmation, each with a firing control on the same corpus:
The 19th,
PluginContext, is mentioned in prose on two pages but has no section:grep -rn '^## PluginContext' content/docs/references/answers 0 while the control^## SpecialOperatoranswers 1.What landed
packages/spec/scripts/lib/unemitted-schemas.ts— the pure adjudication and the cause classifier, extracted for the same reasondef-key-collisionsandzod-graphwere: the only other way to assert on them is to run the whole generator.packages/spec/unemitted-schemas.baseline.json— the committed, hand-edited ledger. Shrink-only in both directions; every entry carries acauseand a prosereason, both re-checked on every run.packages/spec/scripts/build-schemas.ts— collects the population and adjudicates it, last of the ratchets so every red the file could already produce keeps its exact wording and precedence.The fences this respects
mainthe moment it lands. It refuses growth; it does not turn today's 23 into a red build, and it hides none of them — the accepted population is printed in full, with its reasons, on every run (same discipline as an authorised default change, spec 门禁盲区:可作者化 key 的「默认值 / 约束」变更不被任何 gate、tombstone 或 conversion 记录(#4650 / #4659 同族) #4666).json-schema.manifest/is byte-identical: the build prints no📒line.gen:script for the ledger, on purpose — the same reasoningentry-nameability.baseline.jsonrecords: a generator would let a new un-emitted export be admitted by running a command instead of by a decision, which is the whole failure mode being closed.reasonmay not be empty, and the gate enforces it. Triage's fence, verbatim: "Red before green — the ratchet was seen to fail before it was seen to pass
Triage's acceptance criterion 2: "⛔ 只在落基线后跑一次绿的棘轮,与没有棘轮无法区分".
1 — empty ledger, current tree.
pnpm --filter @objectstack/spec check:authorable-surface, exit 1:2 — ledger seeded with the 23 reasoned entries. Same command, exit 0:
3 — write mode too.
pnpm --filter @objectstack/spec build(whose first step isgen:schema), exit 0, same report, and no📒manifest rewrite.The ablation: the gate can fail, and it did not take the other ratchet's domain
One script, both legs,
trap … EXIT INT TERMwith absolute paths. Mutation landing proved by anchor count and blob hash, never by an editor's exit code.build-schemas.tsimports every namespace as../src/NAMESPACE— relative source undertsx— and names no@objectstack/specspecifier, so nodist/is on this subject's resolution path and the built-artifact preflight does not apply here. The on-disk proof below stands in its place.Leg A — a synthetic un-emitted export appended to
src/qa/index.ts:Leg B — an emitted family removed from its barrel (
export * from './validation.zod';insrc/data/index.ts):Restore proof:
git diff HEAD→ 0 bytes;git status --porcelain→ empty.Leg B is triage's acceptance criterion 4 run for real: deleting an emitted export still reddens the old ratchet, and the new one does not replace it. The
--checkfixturedoes not replace the disappearance ratchet…pins the same separation permanently.Tests
packages/spec/scripts/unemitted-schemas.test.ts— 24 cases over the pure logic: the classifier's whole family table plus itsotherdegradation (a Zod re-wording must surface as a ledger mismatch, never as a crash), growth refused,repairedvsvanishedkept apart, cause mismatch, empty reason, keying by export rather than schema name, the committed ledger's own shape, and a malformed ledger rejected loudly rather than read as empty. 24 passed.packages/spec/scripts/build-schemas-check-mode.test.ts— 7 end-to-end cases through the real script in its sandbox: growth, a stale entry, a phantom entry, a wrong cause, an empty reason, the disappearance-ratchet separation, and a green negative control that also re-proves the committed ledger describes this tree. Every failing case asserts the ledger is byte-identical afterwards (a check reports, it never writes —check:authorable-surface在 --check 模式下仍会写json-schema.manifest.json—— 一个「检查」在改工作区 #4711).build-schemas-check-mode.test.ts: 70 passed (70).build-schemasor the new module — 18 files, 494 passed.pnpm --filter @objectstack/spec typecheck(tsc +check:scripts-typecheck+check:test-typecheck) — exit 0.pnpm --filter @objectstack/spec build— exit 0.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom this diff, then reconciled with--ran: 57 derived, 55 measured green, 2 NOT MEASURED. The two arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3PREREQUISITE NOT MET— each needs the whole workspace built and each says in its own words that this is not a pass and not a finding. CI builds the closure and runs them.Repo-wide lint ran in full rather than narrowed:
eslint . --no-inline-config --format jsonover 6379 files — 0 errors, 0 warnings, atff45002b9.No changeset — measured, not assumed
@objectstack/spec'sfileswhitelist isdist · json-schema · liveness · prompts · llms.txt · README.md · src/**/*.zod.ts · CHANGELOG.md · api-surface · spec-changes.json. After a full build:Everything this PR touches lives under
packages/spec/scripts/**or at the package root outsidefiles, andjson-schema.manifest/did not move. Nothing published moves ⇒skip-changeset.验收备注
hookmetadata type's authorable keys reach no key-level ratchet —HookSchemaemits no JSON Schema, soauthorable-surface/holds zerodata/Hook:keys #16906 —hookis a declared metadata type authors write (**/*.hook.yml,allowRuntimeCreate: true), yetHookSchemaemits nothing, soauthorable-surface/holds zerodata/Hook:keys against 14data/HookContext:and 43data/Object:controls. Its authorable keys are therefore outside the key ratchet ([P3] Retire the three deprecated aliases — via the ADR-0087 D2 conversion layer, not by deleting the keys #3855 / authorable-surface 的 tombstone 门禁可被手编基线绕过 —— 删掉基线行就删掉了证据(#4638 / #4643 已两次这样过绿) #4650) and the default ratchet (spec 门禁盲区:可作者化 key 的「默认值 / 约束」变更不被任何 gate、tombstone 或 conversion 记录(#4650 / #4659 同族) #4666) as well as the reference tree. Different cause (z.custom) and different remedy from [finding] Five filter operators ($gt/$gte/$lt/$lte/$between) reach NO published reference page —build-schemas.tsskips their whole schema over an unrepresentablez.date(), and the skip is silent #16431's (a)/(b), so it is its own card rather than a rider here.Skipped: N (unsupported types: function, date, bigint, custom)names a fixed type list that does not match the tree it is summarising — today's causes arefunction,date,customandundefined, andbigintis not among them. It is a cosmetic parenthetical on a line the new report supersedes; left alone deliberately, so this PR changes no existing output. Carrier if anyone touches it: whoever lands [finding] Five filter operators ($gt/$gte/$lt/$lte/$between) reach NO published reference page —build-schemas.tsskips their whole schema over an unrepresentablez.date(), and the skip is silent #16431 (a).Cloud.EnvironmentArtifactSchema/System.EnvironmentArtifactSchemaare the strongest repair candidates in the ledger — a wire contract between the control plane and the runtime that is unprojectable only throughmetadata.onEnable. Recorded as such in the ledger entry itself, where the next reader of that contract will meet it.Generated by Claude Code