From 1d6a48877d8f55272cb11d6ce28104a70d402c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:48:29 +0000 Subject: [PATCH] =?UTF-8?q?build(spec):=20strictness=20=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=E6=95=B0=E5=AD=97/=E6=95=A3=E6=96=87=E5=88=86=E5=AE=B6=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=AE=A1=E6=95=B0=E8=BD=AC=20os-regen=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=89=A9,=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E4=BF=9D=E6=8C=81=E6=89=8B=E5=86=99=20(#5107?= =?UTF-8?q?,=20#5072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台账的合并冲突全部落在数字上:两个批次各按自己那份正确的增量减表头,git 干净 合并互不重叠的行,而与谁都不冲突的小计行「干净合并、两边都错」—— 单日 7 例。 散文当天只冲突一次,而且那次有意义。 - 新增生成物 docs/audits/…counts.md(gen:strictness-ledger),承载每文件计数、 段表头、按类小计、posture 分布;加进 .gitattributes 的 merge=os-regen - 台账只留 Class 判定 / 依据 / findings log / 豁免记录 —— 重生成会删证据, 所以 docs/audits/** 仍在 NOT_DRIVER_MANAGED - check:strictness-ledger 职责反转为「生成物新鲜 + 散文自洽」,双向仍红: 改一个生成的数字红,删一条手写行红(且不会静默变成 0) - #5072 同一把尺:postureOf 不再对 strictObject( 短路,idiom 只定起始姿态, 链一律走完 —— ui/ strict 119→117、passthrough 3→5,strip 计数不变 零 packages/spec/src/**/*.zod.ts 语义改动。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../strictness-ledger-numbers-prose-split.md | 20 + .claude/skills/pm-dispatch/SKILL.md | 9 +- .gitattributes | 22 +- ...07-unknown-key-strictness-ledger.counts.md | 283 ++++++++ .../2026-07-unknown-key-strictness-ledger.md | 512 ++++++++------ packages/spec/package.json | 3 +- .../build-strictness-ledger-counts.mts | 59 ++ packages/spec/scripts/check-generated.ts | 26 +- .../spec/scripts/check-strictness-ledger.mts | 407 +++++------ .../spec/scripts/lib/strictness-ledger-doc.ts | 643 ++++++++++++++++++ .../spec/scripts/lib/strictness-ledger.ts | 40 +- .../scripts/strictness-ledger-doc.test.ts | 181 +++++ .../spec/scripts/strictness-ledger.test.ts | 78 +++ scripts/regen-artifacts.mjs | 18 +- 14 files changed, 1846 insertions(+), 455 deletions(-) create mode 100644 .changeset/strictness-ledger-numbers-prose-split.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts.md create mode 100644 packages/spec/scripts/build-strictness-ledger-counts.mts create mode 100644 packages/spec/scripts/lib/strictness-ledger-doc.ts create mode 100644 packages/spec/scripts/strictness-ledger-doc.test.ts diff --git a/.changeset/strictness-ledger-numbers-prose-split.md b/.changeset/strictness-ledger-numbers-prose-split.md new file mode 100644 index 0000000000..a9cdd9cdba --- /dev/null +++ b/.changeset/strictness-ledger-numbers-prose-split.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +--- + +tooling: strictness 台账「数字/散文分家」—— 计数转生成物走 os-regen,判定与依据保持手写 (#5107, #5072) + +`docs/audits/2026-07-unknown-key-strictness-ledger.md` 是战役期间全仓最热的合并冲突点,而冲突全部落在它的**数字**上:两个批次各按自己那份正确的增量去减表头,git 把互不重叠的**行**干净合并,而与谁都不冲突的**小计行**「干净合并、两边都错」—— 单日 7 例,`ui/` 小计被三个批次分别写成 119 / 110 / 100,而合并后的正确值 91 三边都没写过。**散文当天只冲突过一次,而且那次是有意义的。** + +所以数字走了。新增生成物 `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md`(`gen:strictness-ledger`),承载每文件站点数/strip 数、各段表头、按类小计、posture 分布与未细分目录的总数;`.gitattributes` 把它加进 `merge=os-regen`(#4675),合并时不做文本合并、整体重生成,`pre-commit` 在重生成之前拒绝提交。台账本体只留承重的部分:`Class` 判定、依据、findings log、豁免记录 —— 这些是判断,不是算术,重生成会**删掉别人的证据**,所以 `docs/audits/**` 仍在 `NOT_DRIVER_MANAGED` 里。 + +`check:strictness-ledger` 的职责随之反转,双向闸语义完整保留: + +- **生成物新鲜**:整份重新渲染后逐字节比对,失败信息把「某个数字动了 = 有 schema 在没人重新审视的 `Class` 判定下被增删/改姿态」这句话说出来 —— 这正是旧的手写计数唯一值得留下的那一半; +- **散文自洽**:每条手写行必须指向一个仍然存在、仍然有站点的文件(旧的计数检查顺带买到的性质),有站点却没有行仍然红,strip 行归零仍然红(反向钉)。 + +两条红路径都先证了红再信绿:改生成物里一个数字 → EXIT=1;删一条手写行 → EXIT=1(并且被删的行不会静默变成 0,它会以 `⚠️ unclassified` 出现在生成物里)。 + +小计是「对判断做算术」,所以 remaining-strip 那张表的 `Class` 单元格现在有语法:` [(p)] [· , …]`。已解决的 `mixed`/`split` **必须**声明自己的拆分,闸门拒绝猜 —— 一个宽容的解析器会把这些数字原封不动地送回它们刚被搬走的地方,而且是在一个绿色的文件里。迁移后所有已发布的数字逐一复现(235 strip / 36 open files / authorable 29 / unresolved 33 / no door 38 / no gate 31)。 + +**同一把尺的搭车修复(#5072)**:`postureOf()` 对战役自己的 helper 短路 —— `strictObject(` 直接返回 `strict`,不看链上挂了什么,于是 `strictObject(…).passthrough()`(运行期**开放**的形状)在台账里被记成 **strict**。全仓恰好 2 处,都在 `ui/view.zod.ts`(`GanttConfigSchema` / `TreeConfigSchema`),两处的 `.passthrough()` 都是刻意的。现在 idiom 只决定**起始**姿态,链一律走完,最后一个显式调用赢。`ui/` 的 strict 读数 119 → 117、passthrough 3 → 5;**strip 计数不变**,所以 remaining-strip 那张双向表的数字一个都没动。零 `*.zod.ts` 语义改动。 diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 09b23d4e37..2a36ec84dd 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -915,8 +915,8 @@ Verdict per issue: **A. 碰生成物的 PR,入队前必须先同步 + 整体重生成。** 第 3 步只保证**同一批内** file-disjoint;它管不到**先后两单都碰 `packages/spec` 生成物**的情形 —— 而协议变更 -几乎必然如此。`.gitattributes` 把这七条路径路由到 `merge=os-regen`(⛔ 别只记住前 -五条 —— 后两条是文档产物,同样会被静默吞): +几乎必然如此。`.gitattributes` 把这八条路径路由到 `merge=os-regen`(⛔ 别只记住前 +五条 —— 后三条是文档产物,同样会被静默吞): ``` packages/spec/spec-changes.json @@ -925,9 +925,14 @@ packages/spec/json-schema.manifest.json packages/spec/api-surface.json packages/spec/api-surface-signatures.json docs/protocol-upgrade-guide.md +docs/audits/2026-07-unknown-key-strictness-ledger.counts.md content/docs/references/** ``` +最后那条是 #5107 加的:strictness 台账的**数字**转成了生成物(`gen:strictness-ledger`), +散文仍手写在同名的 `.md` 里。派 #4001 后续批次时要分清 —— **台账正文照常文本合并 +(它一直合得很干净),只有 `.counts.md` 走驱动**。 + 权威清单是 `.gitattributes` 本身(`grep os-regen .gitattributes`),不是这份拷贝 —— 它增删过,以文件为准。 diff --git a/.gitattributes b/.gitattributes index 60fb8aa457..254aa62ba2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,11 +27,19 @@ # dual-source-exports.baseline.json (shrink-only ratchets — recomputing can # WIDEN them), variant-docs.json and the migrations/conversions registries # (hand-written). Those conflicts are for a human. See NOT_DRIVER_MANAGED. +# +# The strictness ledger's COUNTS file joined at #5107 — the ledger's numbers were +# the repo's hottest conflict surface and merged in the one way that hides: two +# batches each decrement a header by their own correct delta, the rows merge +# cleanly because they do not overlap, and the subtotal merges clean and WRONG +# (seven cases in one day). Note it is the counts file, not the ledger — the +# ledger's prose is hand-written and must never be resolved by regenerating. -packages/spec/spec-changes.json merge=os-regen -packages/spec/authorable-surface.json merge=os-regen -packages/spec/json-schema.manifest.json merge=os-regen -packages/spec/api-surface.json merge=os-regen -packages/spec/api-surface-signatures.json merge=os-regen -docs/protocol-upgrade-guide.md merge=os-regen -content/docs/references/** merge=os-regen +packages/spec/spec-changes.json merge=os-regen +packages/spec/authorable-surface.json merge=os-regen +packages/spec/json-schema.manifest.json merge=os-regen +packages/spec/api-surface.json merge=os-regen +packages/spec/api-surface-signatures.json merge=os-regen +docs/protocol-upgrade-guide.md merge=os-regen +docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen +content/docs/references/** merge=os-regen diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md new file mode 100644 index 0000000000..287264c243 --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -0,0 +1,283 @@ + + + +# Unknown-key strictness ledger — the counts (generated) + +Every number the #4001 strictness ledger publishes, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The verdicts, the evidence and +the exemption rationales live in [the ledger itself](./2026-07-unknown-key-strictness-ledger.md) and +are hand-written; **this file has no prose to preserve** and is regenerated whole. + +Split out at #5107. These numbers were the ledger's entire merge-conflict surface: +two batches each decrement a header by their own delta, git merges the rows cleanly, +and the subtotal — which conflicts with nothing — merges clean and wrong. Seven cases +in one day. The correct resolution was always "recompute from the merged tree", so the +path carries `merge=os-regen` (#4675) and the recomputation is now mandatory rather +than remembered. **Never hand-patch a number here** — fix the code or the verdict and +regenerate. + +## Global + +| Measure | Value | +|---|---| +| Triaged directories | 5 | +| Object sites in them | 482 | +| Still-open (strip) sites | 235 | +| Files carrying at least one | 36 | + +Remaining strip sites by class: + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 29 | +| unresolved — needs a per-schema verdict | 33 | +| wire / open — out of forced scope | 104 | +| no door — no carrier, ADR-0049 territory | 38 | +| no gate — carrier live, no parse | 31 | + +## Posture, per triaged directory + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `ui/` | 198 | 117 | 5 | 0 | 76 | +| `data/` | 162 | 41 | 1 | 0 | 120 | +| `automation/` | 75 | 49 | 0 | 0 | 26 | +| `security/` | 20 | 7 | 0 | 0 | 13 | +| `studio/` | 27 | 27 | 0 | 0 | 0 | +| **total** | **482** | **241** | **6** | **0** | **235** | + +## File-level triage — site counts + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +### `ui/` — sites + +| File | Sites | +|---|---| +| `action.zod.ts` | 8 | +| `animation.zod.ts` | 4 | +| `app.zod.ts` | 18 | +| `bulk-action.zod.ts` | 3 | +| `chart.zod.ts` | 7 | +| `component.zod.ts` | 29 | +| `dashboard.zod.ts` | 11 | +| `dataset.zod.ts` | 4 | +| `dnd.zod.ts` | 4 | +| `i18n.zod.ts` | 6 | +| `keyboard.zod.ts` | 4 | +| `notification.zod.ts` | 1 | +| `offline.zod.ts` | 3 | +| `page.zod.ts` | 7 | +| `report.zod.ts` | 3 | +| `responsive.zod.ts` | 4 | +| `sharing.zod.ts` | 2 | +| `theme.zod.ts` | 14 | +| `touch.zod.ts` | 7 | +| `view.zod.ts` | 50 | +| `widget.zod.ts` | 9 | +| **total** | **198** | + +### `data/` — sites + +| File | Sites | +|---|---| +| `analytics.zod.ts` | 8 | +| `data-engine.zod.ts` | 13 | +| `datasource.zod.ts` | 6 | +| `document.zod.ts` | 8 | +| `driver-nosql.zod.ts` | 10 | +| `driver-sql.zod.ts` | 2 | +| `driver.zod.ts` | 9 | +| `driver/memory.zod.ts` | 6 | +| `driver/mongo.zod.ts` | 1 | +| `driver/mysql.zod.ts` | 1 | +| `driver/postgres.zod.ts` | 1 | +| `driver/sqlite.zod.ts` | 2 | +| `external-catalog.zod.ts` | 4 | +| `external-lookup.zod.ts` | 12 | +| `field-value.zod.ts` | 2 | +| `field.zod.ts` | 11 | +| `filter.zod.ts` | 11 | +| `hook-body.zod.ts` | 2 | +| `hook.zod.ts` | 6 | +| `mapping.zod.ts` | 3 | +| `object.zod.ts` | 20 | +| `query.zod.ts` | 5 | +| `seed-loader.zod.ts` | 12 | +| `seed.zod.ts` | 1 | +| `validation.zod.ts` | 6 | +| **total** | **162** | + +### `automation/` — sites + +| File | Sites | +|---|---| +| `approval.zod.ts` | 4 | +| `bpmn-interop.zod.ts` | 5 | +| `builtin-node-config.zod.ts` | 8 | +| `control-flow.zod.ts` | 5 | +| `etl.zod.ts` | 10 | +| `execution.zod.ts` | 13 | +| `flow-function.zod.ts` | 1 | +| `flow.zod.ts` | 11 | +| `io-node-config.zod.ts` | 2 | +| `node-executor.zod.ts` | 4 | +| `schemaless-node-config.zod.ts` | 4 | +| `state-machine.zod.ts` | 6 | +| `time-relative-trigger.zod.ts` | 1 | +| `webhook.zod.ts` | 1 | +| **total** | **75** | + +### `security/` — sites + +| File | Sites | +|---|---| +| `explain.zod.ts` | 11 | +| `permission.zod.ts` | 4 | +| `rls.zod.ts` | 3 | +| `sharing.zod.ts` | 2 | +| **total** | **20** | + +### `studio/` — sites + +| File | Sites | +|---|---| +| `flow-builder.zod.ts` | 7 | +| `object-designer.zod.ts` | 12 | +| `plugin.zod.ts` | 8 | +| **total** | **27** | + +## Remaining strip sites — the batch-planning map + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +### `ui/` — open + +**76 strip of 198**, in 13 file(s). + +| File | Strip | Sites | +|---|---|---| +| `animation.zod.ts` | 4 | 4 | +| `app.zod.ts` | 1 | 18 | +| `chart.zod.ts` | 2 | 7 | +| `component.zod.ts` | 29 | 29 | +| `dnd.zod.ts` | 4 | 4 | +| `i18n.zod.ts` | 5 | 6 | +| `keyboard.zod.ts` | 4 | 4 | +| `notification.zod.ts` | 1 | 1 | +| `offline.zod.ts` | 3 | 3 | +| `sharing.zod.ts` | 1 | 2 | +| `touch.zod.ts` | 7 | 7 | +| `view.zod.ts` | 6 | 50 | +| `widget.zod.ts` | 9 | 9 | +| **total** | **76** | **198** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 7 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 0 | +| no door — no carrier, ADR-0049 territory | 38 | +| no gate — carrier live, no parse | 31 | + +### `data/` — open + +**120 strip of 162**, in 16 file(s). + +| File | Strip | Sites | +|---|---|---| +| `analytics.zod.ts` | 8 | 8 | +| `data-engine.zod.ts` | 13 | 13 | +| `document.zod.ts` | 8 | 8 | +| `driver-nosql.zod.ts` | 10 | 10 | +| `driver-sql.zod.ts` | 2 | 2 | +| `driver.zod.ts` | 9 | 9 | +| `driver/memory.zod.ts` | 5 | 6 | +| `external-catalog.zod.ts` | 4 | 4 | +| `external-lookup.zod.ts` | 12 | 12 | +| `field-value.zod.ts` | 1 | 2 | +| `field.zod.ts` | 3 | 11 | +| `filter.zod.ts` | 11 | 11 | +| `hook.zod.ts` | 4 | 6 | +| `object.zod.ts` | 14 | 20 | +| `query.zod.ts` | 4 | 5 | +| `seed-loader.zod.ts` | 12 | 12 | +| **total** | **120** | **162** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 22 | +| unresolved — needs a per-schema verdict | 33 | +| wire / open — out of forced scope | 65 | +| no door — no carrier, ADR-0049 territory | 0 | +| no gate — carrier live, no parse | 0 | + +### `automation/` — open + +**26 strip of 75**, in 5 file(s). + +| File | Strip | Sites | +|---|---|---| +| `bpmn-interop.zod.ts` | 5 | 5 | +| `etl.zod.ts` | 3 | 10 | +| `execution.zod.ts` | 13 | 13 | +| `flow.zod.ts` | 1 | 11 | +| `node-executor.zod.ts` | 4 | 4 | +| **total** | **26** | **75** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 0 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 26 | +| no door — no carrier, ADR-0049 territory | 0 | +| no gate — carrier live, no parse | 0 | + +### `security/` — open + +**13 strip of 20**, in 2 file(s). + +| File | Strip | Sites | +|---|---|---| +| `explain.zod.ts` | 11 | 11 | +| `rls.zod.ts` | 2 | 3 | +| **total** | **13** | **20** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 0 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 13 | +| no door — no carrier, ADR-0049 territory | 0 | +| no gate — carrier live, no parse | 0 | + +### `studio/` — open + +**0 strip of 27**, in 0 file(s). + +This directory is closed. + +## Other directories (untriaged) + +Site totals only — these directories are classified coarsely in the ledger, per +directory rather than per file. + +| Dir | Sites | +|---|---| +| `ai/` | 77 | +| `api/` | 393 | +| `cloud/` | 82 | +| `identity/` | 33 | +| `integration/` | 10 | +| `kernel/` | 319 | +| `qa/` | 6 | +| `shared/` | 25 | +| `system/` | 368 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 0a53a4d52e..58b7a815bd 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -11,6 +11,43 @@ for, so the `.strict()` ratchet advances on evidence instead of a one-shot sweep. **1885 sites was never the target number** — a large share of the spec is wire/response shape where strictness would be a forward-compat bug. +## Format contract — numbers are generated, verdicts are hand-written (#5107) + +| | Where it lives | Who writes it | +|---|---|---| +| Site counts, strip counts, section headers, per-class subtotals, posture totals | [**`…strictness-ledger.counts.md`**](./2026-07-unknown-key-strictness-ledger.counts.md) — generated | `pnpm --filter @objectstack/spec gen:strictness-ledger` | +| `Class` verdicts, evidence, findings log, exemption rationales, batch history | **this file** — hand-written | you | + +**Why the split.** Every merge conflict this file produced during the campaign +landed in its numbers, and they merged in the one way that does not announce +itself: two batches each delete their own rows and decrement a header by their +own *correct* delta, git merges the rows cleanly because they do not overlap, +and the subtotal — which conflicts with nothing — merges **clean and wrong**. +Seven cases in a single day; the `ui/` subtotal was written as 119, 110 and 100 +by three batches whose merge is 91, a number no branch ever wrote down. The +prose conflicted once, and that conflict was meaningful. So the numbers left. + +**Rules.** + +- **Never hand-edit the counts file, and never hand-patch a number in it.** It + carries `merge=os-regen` (#4675): a merge does not text-merge it, it records + it as pending and `pre-commit` refuses the commit until it is regenerated from + the merged tree — the only state in which the numbers are right. +- **Do not write counts into a row.** Add the row with its `Class` verdict and + its evidence, then regenerate. +- **`check:strictness-ledger` gates both halves**: the artifact must be fresh + against the AST, *and* every row here must name a file that still exists and + still has the sites the row is a verdict about. Both directions fail loudly. +- The `Class` cell in the **remaining-strip map** is machine-read (the subtotals + are arithmetic over it), so it has a grammar: + ` [(p)] [· , …]` where `` is one of + `authorable` · `verify` · `mixed` · `split` · `wire` · `open` · `no door` · + `no gate`. `verify` counts as authorable (a readiness flag, not a class). A + `mixed`/`split` row still carrying `(p)` counts as *unresolved*; one that has + been resolved **must** state its own split (`mixed · 6 authorable`) — the gate + refuses to guess, because a guess would be published as a confident subtotal. + The `Class` column in the **triage** table is free prose and is not parsed. + ## Classification rule One question decides the class: **who writes this schema's input?** @@ -562,96 +599,103 @@ block) when `position` joined the ratchet. ## File-level triage — the five authorable directories -Site counts are object sites — every `z.object(` / `strictObject(` / +**The per-file site counts live in +[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#file-level-triage--site-counts), +not here** (#5107). A site is every `z.object(` / `strictObject(` / `z.strictObject(` / `z.looseObject(` CALL, read from the AST rather than matched textually (see `scripts/lib/strictness-ledger.ts` for why the textual method was -wrong in both directions at once). Classification is per the rule above; **(p)** -marks a provisional call made from the file's exports/JSDoc rather than a full -read — verify before tightening (the #4001 "sharing-rule lesson": candidates, -not verdicts). +wrong in both directions at once). What this table carries is the part no AST can +produce: the verdict and the evidence for it. Classification is per the rule +above; **(p)** marks a provisional call made from the file's exports/JSDoc rather +than a full read — verify before tightening (the #4001 "sharing-rule lesson": +candidates, not verdicts). -### `ui/` — 198 sites +Every file below still has to *be* here: `check:strictness-ledger` fails on a +sited file with no row, and — since the counts left — on a row whose file has no +sites left to be a verdict about. -| File | Sites | Class | Note / next action | -|---|---|---|---| -| `action.zod.ts` | 8 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ActionParamSchema` was strict from #3746, but strictness does not recurse and its `options[]` entry was still strip: an option carrying `color` / `visibleWhen` / `icon` / `disabled` parsed clean through `getMetadataTypeSchema('action')` and came back `{ label, value }`. Closed with `strictObject`, NOT `.passthrough()` — the opposite call from `bulk-action.zod.ts`'s option entry two rows up, and made on measurement rather than symmetry: that def reaches the grid verbatim with no spec door in between and objectui's `BulkActionParam` declares an explicit `[key: string]: unknown`, whereas this path has a door that ALREADY strips and lands in the CLOSED `SelectOptionMetadata`. Whether this surface should carry the field-level per-option vocabulary at all is #5016. Earlier note: **9 → 8 at the #4001 re-measurement** — no schema changed; the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | -| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | -| `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | -| `component.zod.ts` | 29 | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | -| `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | -| `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | -| `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction | -| `widget.zod.ts` | 9 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | -| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | -| `chart.zod.ts` | 7 | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | -| `i18n.zod.ts` | 6 | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | -| `responsive.zod.ts` | 4 | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | -| `dataset.zod.ts` | 4 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | -| `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | 4+4+4+7+3 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | -| `report.zod.ts` | 3 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it | -| `notification.zod.ts` | 1 | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema` — and 批 14 measured it as **`no door`**, the fourth class: no carrier key (the barrel is its only importer), unreachable in a 6860-node BFS from the 24 metadata-type roots + `defineStack` (four positive controls passed in the same run; an injected carrier flipped it), and zero `.parse()` outside its own test. objectui consumes its `.shape.variant` as a VOCABULARY, never parsing an authored payload — which is exactly why closing it would buy nothing. Not tightened; ADR-0049 verdict filed as #5015 | -| `sharing.zod.ts` | 2 | **split** | The first row in this ledger to carry two verdicts, and the reason the classification question is per SCHEMA rather than per file. `SharingConfigSchema` is a **live door** — `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it — **strict as of #4001 批 14**. `EmbedConfigSchema` is **`no door`**: nothing in the repo so much as names the symbol, BFS-unreachable, zero parse. Not tightened; ADR-0049 verdict filed as #5015 | - -### `data/` — 162 sites - -| File | Sites | Class | Note | -|---|---|---|---| -| `object.zod.ts` | 20 | authorable | top-level already guarded (#1535); inner blocks partially strict | -| `data-engine.zod.ts` | 13 | wire (p) | engine contract shapes (was 14 — `DataEngineBatchRequestSchema` retired with `IDataEngine.batch?`, #4618) | -| `external-lookup.zod.ts` | 12 | mixed (p) | authored config + wire results | -| `seed-loader.zod.ts` | 12 | mixed (p) | seed file shapes are authored; loader state is runtime | -| `field.zod.ts` | 11 | authorable | partially strict | -| `filter.zod.ts` | 11 | open | query dialect — user data flows through the predicate values; validated semantically elsewhere | -| `query.zod.ts` | 5 | open, **except `SortNodeSchema` → authorable** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites are the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and keep the class. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) | -| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts | -| `datasource.zod.ts` | 6 | authorable | **strict as of #4001 data step** — all 6: `DatasourceSchema` (+ `pool` / `ssl`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. **#4583 B/C dropped two more sites**: the `healthCheck` and `retryPolicy` blocks are gone — nothing scheduled a probe and nothing retried, so their strictness was validating a shape no code consumed. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged -| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | -| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | 1+2 | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | -| `analytics.zod.ts` | 8 | mixed (p) | | -| `document.zod.ts` | 8 | wire (p) | | -| `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | -| `mapping.zod.ts` | 3 | authorable (p) | | -| `external-catalog.zod.ts` | 4 | wire (p) | | -| `validation.zod.ts` | 6 | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions` → `transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it | -| `field-value.zod.ts` / `seed.zod.ts` | 2+1 | mixed (p) | `seed` is strict (registered-types batch). **`field-value` 1 → 2 at the re-measurement**: `FileValueSchema` is `z.looseObject(` — a THIRD object idiom the old counter did not know, so the site was invisible rather than classified. It is deliberately open (an uploaded file's metadata bag); `LocationValueSchema` beside it is the strip site | - -### `automation/` — 75 sites - -| File | Sites | Class | Note | -|---|---|---|---| -| `flow.zod.ts` | 11 | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorId, actionId, params: {…} }` parsed clean and the executor dispatched `input ?? {}` — a successful connector call carrying nothing. Worth recording precisely, because the obvious example is the wrong one: a slip on a REQUIRED key was always loud (it then reads as missing). What `.strip` swallowed here is the OPTIONAL half — the input map, the retry budget, `interrupting: false`, `required: true` — i.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default without a word. `Flow.errorHandling` gained a second chapter at **#4964**: closing it in 批 11 revealed (rather than caused) that its retry keys were a THIRD encoding of the policy #4661 had converged — it spelled the base delay `retryDelayMs` where the shared declaration spells it `backoffMs` and tombstones the old word, so the strictness this row records was, for one release, rejecting an author for having read the newer file. The block now builds from `retryPolicyShape()`. Site count unchanged; only the vocabulary. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) | -| `etl.zod.ts` | 10 | mixed | **7 strict as of #4001 批 12** — the authoring half (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). The other 3 — `ETLPipelineRun` + `.stats` + `.error` — are **deliberately left open**: engine-emitted run state (an id it minted, a status it reached, counters it accumulated), same disposition and same reason as `FlowVersionHistorySchema` above and all of `execution.zod.ts`. The exemption is recorded on the schema itself, not only here, because a note only this file carries is a note the next sweep does not read. The old blanket `authorable (p)` was too wide; verification split it. ⚠️ **Read the classification caveat before reusing this verdict**: `etl.zod.ts` has NO parse site in objectstack / objectui / cloud, so neither half could be settled by pointing at a live call. The 7 are authorable because the exported schema and type ARE the door (`SYNC_ARCHITECTURE.md` and the module's `@example` both hand-write `const p: ETLPipeline = { … }`) — the `webhook.zod.ts` posture. The 3 are wire on the shape's semantics plus settled precedent, NOT on an emit site anyone can point at today; if an ETL engine ever lands and a run result turns out to be operator-authored, that verdict is the one to revisit. Two out-of-scope findings were filed rather than fixed here; **the first is now closed**: the `retry` block was a third retry-policy vocabulary #4661's convergence never reached (#4962 — converged onto `shared/RetryPolicySchema` in the v17 window, together with `flow.errorHandling` (#4964), the fourth. Both were anonymous inline blocks, so the dual-source instrument that drove #4661 could not see them: it asks how many declarations share an exported NAME, and neither has one. 批 12's five curated `retry` entries described that divergence and dissolved with it — the block's site count is unchanged, only its vocabulary). Still open: all nine type aliases export the parsed shape under the bare name, which is why the SYNC_ARCHITECTURE.md pipeline examples do not compile (#4963). **−12 at #4738**: `sync.zod.ts` (the L1 "Simple Sync" file — `DataSyncConfig`, its `ConflictResolution` enum and satellites, formerly this row's co-candidate) was deleted whole rather than hardened: three-repo zero importers, no parse site, defs unreachable from the metadata-type roots (#4650 gate), so there was no author for strictness to protect (#4535 C13+C15). The integration-side `ConflictResolution` → `ConnectorConflictResolution` rename in the same change is name-only and moves no sites | -| `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged | -| `state-machine.zod.ts` | 6 | authorable | **strict as of #4001 批 10** — all six sites (`ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`). **The `(p)` was NOT a formality here.** ADR-0020 retired this XState shape as a *record-lifecycle* declaration — the top-level `workflow` metadata type and `object.stateMachines` are both gone, and a record's transitions live on the `state_machine` VALIDATION RULE instead — so had those been the only doors this file would be DEAD surface, and the correct action would have been to fix its class, not close it. One authoring door survives: `ai/agent.zod.ts`'s `lifecycle` is `StateMachineSchema`, and `agent` is a registered type, so `defineStack({ agents })` / meta REST / the Studio agent form all reach here through `AgentSchema.parse()`. Verified by parse: an agent whose lifecycle carried `stats`, a state with `onn` (one keystroke from `on`) and a `meta` with two unknown keys **parsed clean**, returning a machine with NO transitions at all — the declaration whose whole job is to deny undeclared transitions, silently emptied and reported valid. `.meta` was checked for the #4909 open-slot case and is CLOSED: the hand-written `StateNodeConfig` type declares exactly its four keys (passthrough would open the Zod while `tsc` stayed shut), nothing in the repo reads any `meta` key, and the prior behaviour was strip — an author's `meta` arrived as `{}` — so there was no openness to preserve. ⚠️ `ActionRef` / `GuardRef` are UNIONS: a strict branch's message does not reach the top (zod raises one `invalid_union` whose message is the literal `"Invalid input"`, with the real prescription nested in `issue.errors[]`), which `formatZodError` then flattens away — filed, not fixed here. **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged | -| `control-flow.zod.ts` | 5 | authorable | **strict as of #4001 批 10** — all five sites (`FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch`). The `(p)` resolves to authorable on the executors' own parse seam (`parseNodeConfig`, #4277) plus `validateControlFlow`'s region parse. **`validateControlFlow` is a sibling guard, not a key gate, and the two do not fight**: it answers single-entry / single-exit / acyclic, which no key check can decide, and the schema answers key membership, which no structural check can decide. They meet at exactly one seam — the guard `safeParse`s each region slot before analyzing it, so an undeclared region key now surfaces there as `: invalid region — `, the guard's framing wrapping the schema's prescription. Nothing was duplicated and nothing removed; the guard simply stopped silently repairing its own input before judging it. Two curation entries had to be MEASURED rather than reasoned: the bare edit-distance fallback answers `itemVariable` with **`indexVariable`** — binding the loop INDEX where the author wanted the ITEM — so the alias exists to overrule a confidently wrong suggestion from this campaign's own helper (the `pii` → `min` shape, third instance); and `join`/`joinGateway` needed two DISTINCT prescriptions because `guidance` emits one bullet per key verbatim, so a shared string printed the same paragraph twice. Its test instrument also had to be rebuilt: `region-slots.test.ts` probed every construct with every candidate key at once and depended on `.strip` to discard the mismatches, so it returned "no schema accepts any region" the moment the shapes closed — it failed loudly, which is the only reason this is a footnote and not a fourth finding-3. Structural validation by `validateControlFlow` remains. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling. **#4964 widened that rename to `flow.errorHandling`**, which spelled the base delay the pre-17 way while the shared policy tombstoned it — so the two automation retry surfaces now teach the same word, and the tombstone's prescription names all four surfaces instead of the two #4661 could see | -| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes | -| `approval.zod.ts` | 4 | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) | -| `node-executor.zod.ts` | 4 | wire | executor contract | -| `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them. **Strict as of #4001 批 9**; the node `config` SLOT itself stays open (ADR-0018 keeps `node.type` open, so the slot cannot be closed without closing the plugin extension point). Five `guidance` entries carry the ADR-0087 notify aliases (`to`/`subject`/`body`/`url`/`source`) | -| `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. **Strict as of #4001 批 9.** The curated tables are the `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` prose from `service-automation`'s registration door, plus two entries that door never had: `recordId` (measured on CRUD nodes across the repo's own flow fixtures, read by no executor — on `delete_record` that is #3810 wearing a key that looks like a constraint) and `outputVariable` on `update_record` / `delete_record` (a documented ABSENCE, and the likeliest wrong key precisely because five sibling contracts declare it) | -| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports — it compares `.shape` key sets, so strictness does not move it). Since #4343 `script` and `subflow` ARE parsed at execute time (`parse-config.ts`). **Strict as of #4001 批 9 — and this is the one row in the table where strictness is the FIRST unknown-key gate, not a second one**: `registerFlow()`'s #4277 rejection derives its declared set from a descriptor `configSchema`, so it structurally skips the schemaless class. `decision` stays export-only, closed anyway; its `condition` guidance suppresses a one-edit rename to `conditions` that #4414 proves is the worse outcome | -| `webhook.zod.ts` | 1 | authorable | **strict as of #4001 batch 11**, and the `(p)` resolved to the opposite of what the old note ("spec-only") implied. Three parse doors, not zero: `defineWebhook()`, `defineStack({ webhooks })` via `StackSchema`, and — the one that mattered — `plugin-webhooks`' `bootstrapDeclaredWebhooks`, which re-`parse()`s every declared webhook at BOOT before materializing it into `sys_webhook`, warning and SKIPPING on failure. Which is why the ADR-0010 envelope landed in the same change rather than as a follow-up: both metadata load paths call `applyProtection` on EVERY type, so a package-loaded webhook reaches that boot parse already carrying `_packageId` / `_provenance`. `.strip` discarded them; `.strict()` alone would have converted every package-shipped webhook into a **skipped subscription after redeploy**, with one `warn` to say so. This is the envelope debt the registered-type batches paid down eight times — `webhook` is not a registered type (no `BUILTIN_METADATA_TYPE_SCHEMAS` entry), which is exactly why the invariant test that guards those never looked here. ⚠️ Strictness also rides `.extend()` onto `integration/connector.zod.ts`'s `WebhookConfigSchema` (verified against real zod, and pinned in `connector.test.ts`); its two extra keys are named in `extraKeys` — except `events`, deliberately, because it is also an alias TARGET here and listing it would walk a base-surface author through two rejections into a key the base does not accept (finding 7, arriving via hand-written `extraKeys` rather than the shape) | -| `time-relative-trigger.zod.ts` | 1 | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured. **Strict as of #4001 batch 11**, and closing it turned up one thing the triage did not predict: this schema is `safeParse`d at BIND time by `TimeRelativeTriggerPlugin` (`time-relative-trigger.ts`), not only at authoring — so the descriptor sitting under the deliberately-OPEN node `config` slot (ADR-0018) now has exactly one gate, and it is a runtime one. The behaviour change is the campaign's whole thesis in miniature: `{ …valid, offsetDay: 7 }` used to bind a sweep that ran daily with the author's narrowing discarded; it now refuses to bind and the plugin's warning carries the key and the rename | -| `flow-function.zod.ts` | 1 | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. **Strict as of #4001 batch 11**, and the verify-first pass confirmed that reading exactly — stated in the code rather than left implied, because a tightening must not claim reach it does not have. It is still worth having for the reason the reading first made it look pointless: `normalizeFlowFunctionEntry` takes TWO keys and ignores the rest **by construction**, so a misspelled `effect` was dropped at the schema and then not looked for by the reader — and the failure runs the quiet way. The function registers, runs, and its writes are counted as none, which is precisely what keeps #4354's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) silent on the one run that needed it | +### `ui/` — file-level triage + +| File | Class | Note / next action | +|---|---|---| +| `action.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ActionParamSchema` was strict from #3746, but strictness does not recurse and its `options[]` entry was still strip: an option carrying `color` / `visibleWhen` / `icon` / `disabled` parsed clean through `getMetadataTypeSchema('action')` and came back `{ label, value }`. Closed with `strictObject`, NOT `.passthrough()` — the opposite call from `bulk-action.zod.ts`'s option entry two rows up, and made on measurement rather than symmetry: that def reaches the grid verbatim with no spec door in between and objectui's `BulkActionParam` declares an explicit `[key: string]: unknown`, whereas this path has a door that ALREADY strips and lands in the CLOSED `SelectOptionMetadata`. Whether this surface should carry the field-level per-option vocabulary at all is #5016. Earlier note: **9 → 8 at the #4001 re-measurement** — no schema changed; the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | +| `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | +| `bulk-action.zod.ts` | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | +| `component.zod.ts` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | +| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | +| `app.zod.ts` | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | +| `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction | +| `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | +| `page.zod.ts` | authorable | partially strict (ADR-0089) | +| `chart.zod.ts` | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `i18n.zod.ts` | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | +| `responsive.zod.ts` | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | +| `dataset.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | +| `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | +| `report.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it | +| `notification.zod.ts` | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema` — and 批 14 measured it as **`no door`**, the fourth class: no carrier key (the barrel is its only importer), unreachable in a 6860-node BFS from the 24 metadata-type roots + `defineStack` (four positive controls passed in the same run; an injected carrier flipped it), and zero `.parse()` outside its own test. objectui consumes its `.shape.variant` as a VOCABULARY, never parsing an authored payload — which is exactly why closing it would buy nothing. Not tightened; ADR-0049 verdict filed as #5015 | +| `sharing.zod.ts` | **split** | The first row in this ledger to carry two verdicts, and the reason the classification question is per SCHEMA rather than per file. `SharingConfigSchema` is a **live door** — `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it — **strict as of #4001 批 14**. `EmbedConfigSchema` is **`no door`**: nothing in the repo so much as names the symbol, BFS-unreachable, zero parse. Not tightened; ADR-0049 verdict filed as #5015 | + +### `data/` — file-level triage + +| File | Class | Note | +|---|---|---| +| `object.zod.ts` | authorable | top-level already guarded (#1535); inner blocks partially strict | +| `data-engine.zod.ts` | wire (p) | engine contract shapes (was 14 — `DataEngineBatchRequestSchema` retired with `IDataEngine.batch?`, #4618) | +| `external-lookup.zod.ts` | mixed (p) | authored config + wire results | +| `seed-loader.zod.ts` | mixed (p) | seed file shapes are authored; loader state is runtime | +| `field.zod.ts` | authorable | partially strict | +| `filter.zod.ts` | open | query dialect — user data flows through the predicate values; validated semantically elsewhere | +| `query.zod.ts` | open, **except `SortNodeSchema` → authorable** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites are the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and keep the class. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) | +| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | wire | driver capability contracts | +| `datasource.zod.ts` | authorable | **strict as of #4001 data step** — all 6: `DatasourceSchema` (+ `pool` / `ssl`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. **#4583 B/C dropped two more sites**: the `healthCheck` and `retryPolicy` blocks are gone — nothing scheduled a probe and nothing retried, so their strictness was validating a shape no code consumed. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged +| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | +| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | +| `analytics.zod.ts` | mixed (p) | | +| `document.zod.ts` | wire (p) | | +| `hook.zod.ts` / `hook-body.zod.ts` | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | +| `mapping.zod.ts` | authorable (p) | | +| `external-catalog.zod.ts` | wire (p) | | +| `validation.zod.ts` | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions` → `transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it | +| `field-value.zod.ts` / `seed.zod.ts` | mixed (p) | `seed` is strict (registered-types batch). **`field-value` 1 → 2 at the re-measurement**: `FileValueSchema` is `z.looseObject(` — a THIRD object idiom the old counter did not know, so the site was invisible rather than classified. It is deliberately open (an uploaded file's metadata bag); `LocationValueSchema` beside it is the strip site | + +### `automation/` — file-level triage + +| File | Class | Note | +|---|---|---| +| `flow.zod.ts` | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorId, actionId, params: {…} }` parsed clean and the executor dispatched `input ?? {}` — a successful connector call carrying nothing. Worth recording precisely, because the obvious example is the wrong one: a slip on a REQUIRED key was always loud (it then reads as missing). What `.strip` swallowed here is the OPTIONAL half — the input map, the retry budget, `interrupting: false`, `required: true` — i.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default without a word. `Flow.errorHandling` gained a second chapter at **#4964**: closing it in 批 11 revealed (rather than caused) that its retry keys were a THIRD encoding of the policy #4661 had converged — it spelled the base delay `retryDelayMs` where the shared declaration spells it `backoffMs` and tombstones the old word, so the strictness this row records was, for one release, rejecting an author for having read the newer file. The block now builds from `retryPolicyShape()`. Site count unchanged; only the vocabulary. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) | +| `etl.zod.ts` | mixed | **7 strict as of #4001 批 12** — the authoring half (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). The other 3 — `ETLPipelineRun` + `.stats` + `.error` — are **deliberately left open**: engine-emitted run state (an id it minted, a status it reached, counters it accumulated), same disposition and same reason as `FlowVersionHistorySchema` above and all of `execution.zod.ts`. The exemption is recorded on the schema itself, not only here, because a note only this file carries is a note the next sweep does not read. The old blanket `authorable (p)` was too wide; verification split it. ⚠️ **Read the classification caveat before reusing this verdict**: `etl.zod.ts` has NO parse site in objectstack / objectui / cloud, so neither half could be settled by pointing at a live call. The 7 are authorable because the exported schema and type ARE the door (`SYNC_ARCHITECTURE.md` and the module's `@example` both hand-write `const p: ETLPipeline = { … }`) — the `webhook.zod.ts` posture. The 3 are wire on the shape's semantics plus settled precedent, NOT on an emit site anyone can point at today; if an ETL engine ever lands and a run result turns out to be operator-authored, that verdict is the one to revisit. Two out-of-scope findings were filed rather than fixed here; **the first is now closed**: the `retry` block was a third retry-policy vocabulary #4661's convergence never reached (#4962 — converged onto `shared/RetryPolicySchema` in the v17 window, together with `flow.errorHandling` (#4964), the fourth. Both were anonymous inline blocks, so the dual-source instrument that drove #4661 could not see them: it asks how many declarations share an exported NAME, and neither has one. 批 12's five curated `retry` entries described that divergence and dissolved with it — the block's site count is unchanged, only its vocabulary). Still open: all nine type aliases export the parsed shape under the bare name, which is why the SYNC_ARCHITECTURE.md pipeline examples do not compile (#4963). **−12 at #4738**: `sync.zod.ts` (the L1 "Simple Sync" file — `DataSyncConfig`, its `ConflictResolution` enum and satellites, formerly this row's co-candidate) was deleted whole rather than hardened: three-repo zero importers, no parse site, defs unreachable from the metadata-type roots (#4650 gate), so there was no author for strictness to protect (#4535 C13+C15). The integration-side `ConflictResolution` → `ConnectorConflictResolution` rename in the same change is name-only and moves no sites | +| `execution.zod.ts` | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged | +| `state-machine.zod.ts` | authorable | **strict as of #4001 批 10** — all six sites (`ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`). **The `(p)` was NOT a formality here.** ADR-0020 retired this XState shape as a *record-lifecycle* declaration — the top-level `workflow` metadata type and `object.stateMachines` are both gone, and a record's transitions live on the `state_machine` VALIDATION RULE instead — so had those been the only doors this file would be DEAD surface, and the correct action would have been to fix its class, not close it. One authoring door survives: `ai/agent.zod.ts`'s `lifecycle` is `StateMachineSchema`, and `agent` is a registered type, so `defineStack({ agents })` / meta REST / the Studio agent form all reach here through `AgentSchema.parse()`. Verified by parse: an agent whose lifecycle carried `stats`, a state with `onn` (one keystroke from `on`) and a `meta` with two unknown keys **parsed clean**, returning a machine with NO transitions at all — the declaration whose whole job is to deny undeclared transitions, silently emptied and reported valid. `.meta` was checked for the #4909 open-slot case and is CLOSED: the hand-written `StateNodeConfig` type declares exactly its four keys (passthrough would open the Zod while `tsc` stayed shut), nothing in the repo reads any `meta` key, and the prior behaviour was strip — an author's `meta` arrived as `{}` — so there was no openness to preserve. ⚠️ `ActionRef` / `GuardRef` are UNIONS: a strict branch's message does not reach the top (zod raises one `invalid_union` whose message is the literal `"Invalid input"`, with the real prescription nested in `issue.errors[]`), which `formatZodError` then flattens away — filed, not fixed here. **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged | +| `control-flow.zod.ts` | authorable | **strict as of #4001 批 10** — all five sites (`FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch`). The `(p)` resolves to authorable on the executors' own parse seam (`parseNodeConfig`, #4277) plus `validateControlFlow`'s region parse. **`validateControlFlow` is a sibling guard, not a key gate, and the two do not fight**: it answers single-entry / single-exit / acyclic, which no key check can decide, and the schema answers key membership, which no structural check can decide. They meet at exactly one seam — the guard `safeParse`s each region slot before analyzing it, so an undeclared region key now surfaces there as `: invalid region — `, the guard's framing wrapping the schema's prescription. Nothing was duplicated and nothing removed; the guard simply stopped silently repairing its own input before judging it. Two curation entries had to be MEASURED rather than reasoned: the bare edit-distance fallback answers `itemVariable` with **`indexVariable`** — binding the loop INDEX where the author wanted the ITEM — so the alias exists to overrule a confidently wrong suggestion from this campaign's own helper (the `pii` → `min` shape, third instance); and `join`/`joinGateway` needed two DISTINCT prescriptions because `guidance` emits one bullet per key verbatim, so a shared string printed the same paragraph twice. Its test instrument also had to be rebuilt: `region-slots.test.ts` probed every construct with every candidate key at once and depended on `.strip` to discard the mismatches, so it returned "no schema accepts any region" the moment the shapes closed — it failed loudly, which is the only reason this is a footnote and not a fourth finding-3. Structural validation by `validateControlFlow` remains. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling. **#4964 widened that rename to `flow.errorHandling`**, which spelled the base delay the pre-17 way while the shared policy tombstoned it — so the two automation retry surfaces now teach the same word, and the tombstone's prescription names all four surfaces instead of the two #4661 could see | +| `bpmn-interop.zod.ts` | wire (p) | interop import shapes | +| `approval.zod.ts` | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) | +| `node-executor.zod.ts` | wire | executor contract | +| `io-node-config.zod.ts` | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them. **Strict as of #4001 批 9**; the node `config` SLOT itself stays open (ADR-0018 keeps `node.type` open, so the slot cannot be closed without closing the plugin extension point). Five `guidance` entries carry the ADR-0087 notify aliases (`to`/`subject`/`body`/`url`/`source`) | +| `builtin-node-config.zod.ts` | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. **Strict as of #4001 批 9.** The curated tables are the `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` prose from `service-automation`'s registration door, plus two entries that door never had: `recordId` (measured on CRUD nodes across the repo's own flow fixtures, read by no executor — on `delete_record` that is #3810 wearing a key that looks like a constraint) and `outputVariable` on `update_record` / `delete_record` (a documented ABSENCE, and the likeliest wrong key precisely because five sibling contracts declare it) | +| `schemaless-node-config.zod.ts` | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports — it compares `.shape` key sets, so strictness does not move it). Since #4343 `script` and `subflow` ARE parsed at execute time (`parse-config.ts`). **Strict as of #4001 批 9 — and this is the one row in the table where strictness is the FIRST unknown-key gate, not a second one**: `registerFlow()`'s #4277 rejection derives its declared set from a descriptor `configSchema`, so it structurally skips the schemaless class. `decision` stays export-only, closed anyway; its `condition` guidance suppresses a one-edit rename to `conditions` that #4414 proves is the worse outcome | +| `webhook.zod.ts` | authorable | **strict as of #4001 batch 11**, and the `(p)` resolved to the opposite of what the old note ("spec-only") implied. Three parse doors, not zero: `defineWebhook()`, `defineStack({ webhooks })` via `StackSchema`, and — the one that mattered — `plugin-webhooks`' `bootstrapDeclaredWebhooks`, which re-`parse()`s every declared webhook at BOOT before materializing it into `sys_webhook`, warning and SKIPPING on failure. Which is why the ADR-0010 envelope landed in the same change rather than as a follow-up: both metadata load paths call `applyProtection` on EVERY type, so a package-loaded webhook reaches that boot parse already carrying `_packageId` / `_provenance`. `.strip` discarded them; `.strict()` alone would have converted every package-shipped webhook into a **skipped subscription after redeploy**, with one `warn` to say so. This is the envelope debt the registered-type batches paid down eight times — `webhook` is not a registered type (no `BUILTIN_METADATA_TYPE_SCHEMAS` entry), which is exactly why the invariant test that guards those never looked here. ⚠️ Strictness also rides `.extend()` onto `integration/connector.zod.ts`'s `WebhookConfigSchema` (verified against real zod, and pinned in `connector.test.ts`); its two extra keys are named in `extraKeys` — except `events`, deliberately, because it is also an alias TARGET here and listing it would walk a base-surface author through two rejections into a key the base does not accept (finding 7, arriving via hand-written `extraKeys` rather than the shape) | +| `time-relative-trigger.zod.ts` | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured. **Strict as of #4001 batch 11**, and closing it turned up one thing the triage did not predict: this schema is `safeParse`d at BIND time by `TimeRelativeTriggerPlugin` (`time-relative-trigger.ts`), not only at authoring — so the descriptor sitting under the deliberately-OPEN node `config` slot (ADR-0018) now has exactly one gate, and it is a runtime one. The behaviour change is the campaign's whole thesis in miniature: `{ …valid, offsetDay: 7 }` used to bind a sweep that ran daily with the author's narrowing discarded; it now refuses to bind and the plugin's warning carries the key and the rename | +| `flow-function.zod.ts` | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. **Strict as of #4001 batch 11**, and the verify-first pass confirmed that reading exactly — stated in the code rather than left implied, because a tightening must not claim reach it does not have. It is still worth having for the reason the reading first made it look pointless: `normalizeFlowFunctionEntry` takes TWO keys and ignores the rest **by construction**, so a misspelled `effect` was dropped at the schema and then not looked for by the reader — and the failure runs the quiet way. The function registers, runs, and its writes are counted as none, which is precisely what keeps #4354's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) silent on the one run that needed it | `trigger-registry.zod.ts` had a row here (11 sites, "mixed — descriptors are code-registered (wire-ish); bindings authored") until #4499 deleted the file: all 11 sites were the third connector-vocabulary declaration (`ConnectorSchema` / `Authentication*` / `Operation*` / `ConnectorInstance`), and the old row's classification was optimistic twice over — nothing was ever code-registered against these descriptors and no binding was ever authored. The engine registers against `integration/connector.zod.ts` (ADR-0097), which keeps its own row. -### `security/` — 20 sites +### `security/` — file-level triage -| File | Sites | Class | Note | -|---|---|---|---| -| `explain.zod.ts` | 11 | wire | permission-explain responses — never strict | -| `permission.zod.ts` | 4 | authorable | **strict as of #4001**; `EffectiveObjectPermissionSchema` explicitly `.strip()`s (wire) | -| `rls.zod.ts` | 3 | authorable | **`RowLevelSecurityPolicySchema` strict as of #4001 step 2** (a stripped RLS key is a silent policy hole); `RLSUserContextSchema` / `RLSEvaluationResultSchema` are runtime shapes — stay tolerant | -| `sharing.zod.ts` | 2 | authorable | **strict as of #4001 step 2** — rule + recipient shapes; strictness and the error map ride the base into the criteria extension | +| File | Class | Note | +|---|---|---| +| `explain.zod.ts` | wire | permission-explain responses — never strict | +| `permission.zod.ts` | authorable | **strict as of #4001**; `EffectiveObjectPermissionSchema` explicitly `.strip()`s (wire) | +| `rls.zod.ts` | authorable | **`RowLevelSecurityPolicySchema` strict as of #4001 step 2** (a stripped RLS key is a silent policy hole); `RLSUserContextSchema` / `RLSEvaluationResultSchema` are runtime shapes — stay tolerant | +| `sharing.zod.ts` | authorable | **strict as of #4001 step 2** — rule + recipient shapes; strictness and the error map ride the base into the criteria extension | -### `studio/` — 27 sites +### `studio/` — file-level triage -| File | Sites | Class | Note | -|---|---|---|---| -| `object-designer.zod.ts` | 12 | authorable | strict as of #4001 — `defineObjectDesignerConfig` is the authoring door | -| `plugin.zod.ts` | 8 | authorable | strict as of #4001 — **was `mixed (p)`; verification found no wire half** | -| `flow-builder.zod.ts` | 7 | authorable | strict as of #4001 — `defineFlowBuilderConfig`; independent of `FlowSchema` | +| File | Class | Note | +|---|---|---| +| `object-designer.zod.ts` | authorable | strict as of #4001 — `defineObjectDesignerConfig` is the authoring door | +| `plugin.zod.ts` | authorable | strict as of #4001 — **was `mixed (p)`; verification found no wire half** | +| `flow-builder.zod.ts` | authorable | strict as of #4001 — `defineFlowBuilderConfig`; independent of `FlowSchema` | **All three provisional verdicts are now verified, and one was wrong.** The deciding evidence is the same lens the registered-type batches used: each file @@ -684,28 +728,39 @@ against counts of `strictObject(` occurrences, which undercount strict sites by every schema closed with the OLDER `z.object(…).strict()` idiom — reading `automation/` as **0 strict** when it has 8, and `ui/` as 49 when it has 72. -This section is that number, per file, and **it is gated** (`check:strictness-ledger`): +This section is the WORKLIST for that number. The number itself — per file, per +directory, and split by class — is +[in the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#remaining-strip-sites--the-batch-planning-map), +generated (#5107). What stays here is the row: which file is still open, and the +per-schema verdict and evidence that say whether its remainder is work or a +deliberate floor. + +Both halves are gated (`check:strictness-ledger`): -- every file with at least one strip site must have a row here, with matching counts; +- every file with at least one strip site must have a row here; - a row whose file reaches **zero** strip sites **fails the gate** — a closed file drops out of this table. That reverse pin is what makes the table a ratchet rather than a snapshot, and it is the lesson from the ADR-0010 debt list applied one level up: *a worklist that can outlive its work will.* +- the counts file must be fresh against the AST, so a schema that moves cannot + leave this map describing a tree that no longer exists. -`Class` here is per SCHEMA, not per file, so a row's strip count can span two -classes; where it does, the split is stated. **Only the authorable half is in the -2026-08-03 ruling's forced scope** — wire/open rows are listed so the arithmetic -is complete and so nobody re-triages them from scratch next batch. +`Class` here is per SCHEMA, not per file, so a row's strip sites can span two +classes; where they do, **the row states the split** (`mixed · 6 authorable`) and +the subtotal is summed from those declarations rather than from a reading of the +prose. **Only the authorable half is in the 2026-08-03 ruling's forced scope** — +wire/open rows are listed so the arithmetic is complete and so nobody re-triages +them from scratch next batch. -#### `automation/` — 26 strip of 75 +#### `automation/` — remaining strip sites -| File | Strip | Sites | Class | Batch | -|---|---|---|---|---| -| `execution.zod.ts` | 13 | 13 | wire | **out of scope** — engine-emitted run state; the ledger row already says "never strict" | -| `etl.zod.ts` | 3 | 10 | wire | **Authorable half closed at 批 12** (7 sites: `ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). `.retry` was re-pointed at the shared `RetryPolicySchema` at #4962 (`maxAttempts` → `maxRetries`, default 3 → 0, three knobs gained) — a vocabulary change inside an already-closed site, so this row's numbers do not move. What is left is `ETLPipelineRun` + `.stats` + `.error` — engine-emitted run state, exempt for the `FlowVersionHistorySchema` reason and pinned as such in `etl.test.ts`, so closing it means deleting a test that says not to. **This row shrinks without disappearing** — the second in `automation/` to do so, after `flow.zod.ts` reached its own wire floor of 1 at 批 11 (the two batches were in flight together and arrived at the same shape independently, which is the better evidence that it is the right one). Worth naming because the reverse pin cannot see it: the pin fires on zero, so a row that stops at its wire floor looks exactly like a row nobody finished. The Class column is the only thing separating them — read it before treating this as unfinished work | -| `flow.zod.ts` | 1 | 11 | wire | **batch 11 closed the 6 authorable** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). (`Flow.errorHandling`'s retry keys were re-pointed at the shared `RetryPolicySchema` at #4964 — a vocabulary change inside an already-closed site, so this row's numbers do not move.) The 1 left is `FlowVersionHistorySchema`, which this table has exempted since it was written — **do not close it**: it is emitted on publish, not authored, so closing it makes a future emitter-side field a parse failure for whoever reads history. The exemption now also lives beside the schema and in `flow.test.ts`, because a row in a table is not where the next person to open that file will look | -| `bpmn-interop.zod.ts` | 5 | 5 | wire (p) | **out of scope** — third-party BPMN import/export shapes; strictness turns an upstream addition into our parse crash | -| `node-executor.zod.ts` | 4 | 4 | wire | **out of scope** — executor registration contract, code-to-code | +| File | Class | Batch | +|---|---|---| +| `execution.zod.ts` | wire | **out of scope** — engine-emitted run state; the ledger row already says "never strict" | +| `etl.zod.ts` | wire | **Authorable half closed at 批 12** (7 sites: `ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). `.retry` was re-pointed at the shared `RetryPolicySchema` at #4962 (`maxAttempts` → `maxRetries`, default 3 → 0, three knobs gained) — a vocabulary change inside an already-closed site, so this row's numbers do not move. What is left is `ETLPipelineRun` + `.stats` + `.error` — engine-emitted run state, exempt for the `FlowVersionHistorySchema` reason and pinned as such in `etl.test.ts`, so closing it means deleting a test that says not to. **This row shrinks without disappearing** — the second in `automation/` to do so, after `flow.zod.ts` reached its own wire floor of 1 at 批 11 (the two batches were in flight together and arrived at the same shape independently, which is the better evidence that it is the right one). Worth naming because the reverse pin cannot see it: the pin fires on zero, so a row that stops at its wire floor looks exactly like a row nobody finished. The Class column is the only thing separating them — read it before treating this as unfinished work | +| `flow.zod.ts` | wire | **batch 11 closed the 6 authorable** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). (`Flow.errorHandling`'s retry keys were re-pointed at the shared `RetryPolicySchema` at #4964 — a vocabulary change inside an already-closed site, so this row's numbers do not move.) The 1 left is `FlowVersionHistorySchema`, which this table has exempted since it was written — **do not close it**: it is emitted on publish, not authored, so closing it makes a future emitter-side field a parse failure for whoever reads history. The exemption now also lives beside the schema and in `flow.test.ts`, because a row in a table is not where the next person to open that file will look | +| `bpmn-interop.zod.ts` | wire (p) | **out of scope** — third-party BPMN import/export shapes; strictness turns an upstream addition into our parse crash | +| `node-executor.zod.ts` | wire | **out of scope** — executor registration contract, code-to-code | Eight rows have left this table across three waves of the ruling's `automation/` main body, each one on reverse-pin evidence — the row was deleted because the @@ -731,16 +786,18 @@ header; 批 11 then merged and its subtotal (`etl` 7 + `state-machine` 6 + right against their own branch and both wrong against the merge; 批 12 made it four, and did so twice — once against 批 10 and again against 批 11 — which is the useful detail, because it means the count is not "once per wave" but once -per *pair* of waves that overlap in flight. So the rule is mechanical rather -than remembered: **the header and the subtotal are recomputed from the surviving -rows, never resolved in favour of a side**, and `check:strictness-ledger`'s -arithmetic is what settles it. A clean-looking merge here is evidence of nothing. - -**Authorable strip in `automation/`: 0 of 26** (was 41 of 67 when the ruling was -written). **The ruling's `automation/` main body is complete** — every remaining -strip site in this directory is wire, and none is in the forced scope: -`execution` 13, `bpmn-interop` 5, `node-executor` 4, `etl`'s 3 run-state shapes, -and `flow.zod.ts`'s last site `FlowVersionHistorySchema`. +per *pair* of waves that overlap in flight. So the rule was stated as a +discipline — **the header and the subtotal are recomputed from the surviving +rows, never resolved in favour of a side** — and #5107 made it a mechanism +instead: neither number is written by hand any more, so there is nothing here for +a merge to get plausibly wrong. A clean-looking merge here was evidence of +nothing, eleven times, which is what finally bought the split. + +**Authorable strip in `automation/`: 0** ([counts file](./2026-07-unknown-key-strictness-ledger.counts.md#automation--open); +was 41 of 67 when the ruling was written). **The ruling's `automation/` main body +is complete** — every remaining strip site in this directory is wire, and none is +in the forced scope: `execution`, `bpmn-interop`, `node-executor`, `etl`'s +run-state shapes, and `flow.zod.ts`'s last site `FlowVersionHistorySchema`. That leaves the section in a state this table has not been in before, and it is the state most likely to be misread: **two rows now sit at a deliberate wire @@ -755,36 +812,37 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 76 strip of 198 - -| File | Strip | Sites | Class | Batch | -|---|---|---|---|---| -| `component.zod.ts` | 29 | 29 | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | -| `view.zod.ts` | 6 | 50 | mixed | **15 of 20 closed at #4001 批 18**, and a sixteenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; the 6 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter reads them as `strict` because `postureOf` returns early on the `strictObject` idiom without walking the chain (**#5072**); it inflates the strict count and does not affect this row's strip count. **Still open, all six measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum names this site: its wire variant must re-open RECURSIVELY, and re-closing here is gated on that. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | -| `widget.zod.ts` | 9 | 9 | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | -| `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | -| `touch.zod.ts` | 7 | 7 | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | -| `i18n.zod.ts` | 5 | 6 | **split** | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | -| `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | -| `dnd.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | -| `keyboard.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | -| `offline.zod.ts` | 3 | 3 | **no door** | ⛔ same as `touch` — #4988 | -| `sharing.zod.ts` | 1 | 2 | **no door** | 批 14: `SharingConfig` was a live door and is **closed**; the 1 left is `EmbedConfigSchema`, which no module in the repo even names (BFS-unreachable, zero parse). **This row shrinks without disappearing** — the first `no door` floor, the same read the `Class` column already has to carry for `flow`'s and `etl`'s wire floors. ADR-0049 verdict: #5015 | -| `app.zod.ts` | 1 | 18 | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching | -| `notification.zod.ts` | 1 | 1 | **no door** | 批 14: `NotificationActionSchema` reclassified, not tightened — no carrier key, BFS-unreachable, zero parse; objectui reads its `.shape` as a vocabulary. ADR-0049 verdict: #5015 | +#### `ui/` — remaining strip sites + +| File | Class | Batch | +|---|---|---| +| `component.zod.ts` | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | +| `view.zod.ts` | mixed · 6 authorable | **15 of 20 closed at #4001 批 18**, and a sixteenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; the 6 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **Still open, all six measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum names this site: its wire variant must re-open RECURSIVELY, and re-closing here is gated on that. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | +| `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | +| `chart.zod.ts` | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | +| `touch.zod.ts` | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | +| `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | +| `animation.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | +| `dnd.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | +| `keyboard.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | +| `offline.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | +| `sharing.zod.ts` | **no door** | 批 14: `SharingConfig` was a live door and is **closed**; the 1 left is `EmbedConfigSchema`, which no module in the repo even names (BFS-unreachable, zero parse). **This row shrinks without disappearing** — the first `no door` floor, the same read the `Class` column already has to carry for `flow`'s and `etl`'s wire floors. ADR-0049 verdict: #5015 | +| `app.zod.ts` | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching | +| `notification.zod.ts` | **no door** | 批 14: `NotificationActionSchema` reclassified, not tightened — no carrier key, BFS-unreachable, zero parse; objectui reads its `.shape` as a vocabulary. ADR-0049 verdict: #5015 | `responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence — it reached 0 strip, the gate went red on the row still being there, and the row was deleted. `action.zod.ts`, `report.zod.ts`, `dataset.zod.ts` and `dashboard.zod.ts` left it the same way at **批 14**, and `theme.zod.ts` at **批 15**. Header and subtotal are -**recomputed from the surviving rows** (29 + 20 + 9 + 2 + 7 + 6 + 4 + 4 + 4 + 3 + -1 + 1 + 1 = 91), not decremented by any batch's own count. That is not -pedantry: it happened four times in one day in `automation/` — each branch's -arithmetic was right against itself, git merged the rows cleanly because they do -not overlap, and the subtotal line, which conflicts with nothing, merged clean and -wrong on both sides. `check:strictness-ledger`'s header arithmetic is what settles -it. +**recomputed from the surviving rows**, never decremented by any batch's own +count. That is not pedantry: it happened four times in one day in `automation/` — +each branch's arithmetic was right against itself, git merged the rows cleanly +because they do not overlap, and the subtotal line, which conflicts with nothing, +merged clean and wrong on both sides. Since #5107 the recomputation is not a rule +anyone can forget: the numbers are generated from the surviving rows plus the +AST, and the merge driver makes regenerating them a precondition of committing +the merge. **批 13, 批 14 and 批 15 are the fifth, sixth and seventh instances — and the first three where the wrong number was this very line.** 批 13 computed 119 @@ -843,29 +901,31 @@ ten — and #5114 made it **eleven**, from the other direction: it REOPENED one branch never wrote either. A reopening moves this line exactly as a closure does. and it is why the subtotal below is recomputed from the surviving rows rather than adjusted by anyone's delta. -**Authorable strip in `ui/`: 7 of 76** (was 123 of 123 when the ruling was -written). Recomputed from the surviving rows after the 批 18 + 批 17 + #5114 merge, not -decremented: 29+6+9+2+7+5+4+4+4+3+1+1+1 = 76, of which **69** are the two -no-parse classes, leaving the authorable half as `view` 6 + `app` 1 = **7**. -`app.zod.ts`'s single site is held pending the finding-16 `.extend()` check -rather than counted as ready. - -**69 of the 76 — 91% of what is left in this directory — are the two no-parse -classes.** After 批 18 closed 15 real doors, 批 17 measured 29 sites as having -none, and #5114 reopened one, `ui/` has **seven** authorable strip sites left in -total. That is the single -largest fact about this directory now, and it should be read before any further -`ui/` strictness batch is scheduled — the ratchet is very nearly done here, and -what remains open is overwhelmingly work for OTHER issues: - -- **38 `no door`** — `touch` (7), `animation` (4), `dnd` (4), `keyboard` (4) and - `offline` (3) from 批 13; `sharing.zod.ts`'s `EmbedConfig` and - `notification.zod.ts`'s `NotificationAction` from 批 14; `widget.zod.ts` (9) - plus `i18n.zod.ts`'s remaining 5 from 批 16 (#4988, #5015, #5055). -- **31 `no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all 29 - sites of `component.zod.ts` from 批 17** (#5068). That single row is the - campaign's largest reclassification and the reason this subtotal fell by 29 - without one site being closed. +**Authorable strip in `ui/`: +[see the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#ui--open)** +(it was 123 of 123 when the ruling was written). The subtotal is summed from the +surviving rows' declared `Class` splits, never decremented by a batch's own +delta — eleven instances above are why that is now a generator and not a +discipline. `app.zod.ts`'s single site is held pending the finding-16 `.extend()` +check rather than counted as ready; it is counted as authorable all the same, +which is what `verify` means in the Class grammar. + +**The overwhelming majority of what is left in this directory is the two +no-parse classes** — the exact split is in the counts file, and the direction of +travel is what matters here: 批 18 closed 15 real doors, 批 17 measured 29 sites +as having no parse at all, #5114 reopened one. That is the single largest fact +about this directory now, and it should be read before any further `ui/` +strictness batch is scheduled — the ratchet is very nearly done here, and what +remains open is overwhelmingly work for OTHER issues: + +- **`no door`** — `touch`, `animation`, `dnd`, `keyboard` and `offline` from + 批 13; `sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s + `NotificationAction` from 批 14; `widget.zod.ts` plus `i18n.zod.ts`'s + remainder from 批 16 (#4988, #5015, #5055). +- **`no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all of + `component.zod.ts` from 批 17** (#5068). That single row is the campaign's + largest reclassification and the reason this subtotal fell by 29 without one + site being closed. Read the difference before acting on either: they imply OPPOSITE follow-ups (`no door` → ADR-0049 enforce-or-remove; `no gate` → wire the parse at the @@ -939,54 +999,70 @@ reverse pin above). Worth noting for the next batch that "resolve a row" has two exits, and the reverse pin cannot tell them apart — only the changeset and the triage row record which one was taken. -#### `data/` — 120 strip of 162 - -| File | Strip | Sites | Class | Batch | -|---|---|---|---|---| -| `object.zod.ts` | 14 | 20 | authorable | The registered type's top level is closed (#1535/#4519/#4522); these are inner blocks — `Index`, `ObjectAccessConfig`, `Lifecycle` (+4 sub-blocks), `ObjectFieldGroup`, `ObjectExternalBinding`, `userActions`, `systemFields`, `activityMilestones`, `publicSharing`, `ObjectExtension`. Highest author volume in the repo | -| `data-engine.zod.ts` | 13 | 13 | wire | **out of scope** — engine request/response contracts | -| `external-lookup.zod.ts` | 12 | 12 | mixed (p) | `ExternalDataSource` + `.authentication` and the `ExternalLookup` tree are authored config; needs the per-schema read the ledger never did | -| `seed-loader.zod.ts` | 12 | 12 | mixed (p) | Split is real: `SeedLoaderConfig` / `SeedIdentity` (+`.user`/`.org`) / `ReferenceResolution` are authored; `SeedLoadResult` / `SeedLoaderResult` (+`.summary`) / `ReferenceResolutionError` / `ObjectDependencyNode` / `ObjectDependencyGraph` / `SeedLoaderRequest` are loader runtime | -| `filter.zod.ts` | 11 | 11 | open | **out of scope** — query dialect; user data flows through, validated semantically elsewhere | -| `driver-nosql.zod.ts` | 10 | 10 | wire | **out of scope** | -| `driver.zod.ts` | 9 | 9 | wire | **out of scope** — driver capability contract | -| `analytics.zod.ts` | 8 | 8 | mixed (p) | `Metric` / `Dimension` / `Cube` / `AnalyticsQuery` — cube definitions are authored; needs a per-schema read | -| `document.zod.ts` | 8 | 8 | wire (p) | `DocumentTemplate` / `ESignatureConfig` read authorable on their face — the `(p)` is unresolved, verify before scheduling either way | -| `driver/memory.zod.ts` | 5 | 6 | authorable | The persistence-adapter union under `datasource.config`; `datasource.config` HAS been parsed against these since #4410, so strictness here now binds | -| `query.zod.ts` | 4 | 5 | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 | -| `external-catalog.zod.ts` | 4 | 4 | wire (p) | **out of scope** | -| `hook.zod.ts` | 4 | 6 | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step | -| `field.zod.ts` | 3 | 11 | authorable | `LocationCoordinates` / `CurrencyValue` / `Address` — field VALUE shapes, not field config; check whether they are record data (→ open) before closing | -| `driver-sql.zod.ts` | 2 | 2 | wire | **out of scope** | -| `field-value.zod.ts` | 1 | 2 | mixed (p) | `LocationValueSchema` — record data, very likely **open**; its sibling `FileValueSchema` is already `z.looseObject` | - -**Authorable strip in `data/`: ~22 firm** (`object` 14 + `driver/memory` 5 + `field` 3), **plus ~33 needing a per-schema verdict** (`external-lookup` 12, `seed-loader` 12, `analytics` 8, `field-value` 1). 65 are wire/open and out of the ruling's forced scope — 66 until #4721 closed `query.zod.ts`'s `SortNodeSchema`, which is the one row in this directory where the per-schema read moved a site OUT of `open` rather than confirming it. - -#### `security/` — 13 strip of 20 - -| File | Strip | Sites | Class | Batch | -|---|---|---|---|---| -| `explain.zod.ts` | 11 | 11 | wire | **out of scope** — permission-explain responses; the triage row already says "never strict" | -| `rls.zod.ts` | 2 | 3 | wire | **out of scope** — `RLSUserContext` / `RLSEvaluationResult` are runtime shapes; the POLICY shape is closed | +#### `data/` — remaining strip sites + +| File | Class | Batch | +|---|---|---| +| `object.zod.ts` | authorable | The registered type's top level is closed (#1535/#4519/#4522); these are inner blocks — `Index`, `ObjectAccessConfig`, `Lifecycle` (+4 sub-blocks), `ObjectFieldGroup`, `ObjectExternalBinding`, `userActions`, `systemFields`, `activityMilestones`, `publicSharing`, `ObjectExtension`. Highest author volume in the repo | +| `data-engine.zod.ts` | wire | **out of scope** — engine request/response contracts | +| `external-lookup.zod.ts` | mixed (p) | `ExternalDataSource` + `.authentication` and the `ExternalLookup` tree are authored config; needs the per-schema read the ledger never did | +| `seed-loader.zod.ts` | mixed (p) | Split is real: `SeedLoaderConfig` / `SeedIdentity` (+`.user`/`.org`) / `ReferenceResolution` are authored; `SeedLoadResult` / `SeedLoaderResult` (+`.summary`) / `ReferenceResolutionError` / `ObjectDependencyNode` / `ObjectDependencyGraph` / `SeedLoaderRequest` are loader runtime | +| `filter.zod.ts` | open | **out of scope** — query dialect; user data flows through, validated semantically elsewhere | +| `driver-nosql.zod.ts` | wire | **out of scope** | +| `driver.zod.ts` | wire | **out of scope** — driver capability contract | +| `analytics.zod.ts` | mixed (p) | `Metric` / `Dimension` / `Cube` / `AnalyticsQuery` — cube definitions are authored; needs a per-schema read | +| `document.zod.ts` | wire (p) | `DocumentTemplate` / `ESignatureConfig` read authorable on their face — the `(p)` is unresolved, verify before scheduling either way | +| `driver/memory.zod.ts` | authorable | The persistence-adapter union under `datasource.config`; `datasource.config` HAS been parsed against these since #4410, so strictness here now binds | +| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 | +| `external-catalog.zod.ts` | wire (p) | **out of scope** | +| `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step | +| `field.zod.ts` | authorable | `LocationCoordinates` / `CurrencyValue` / `Address` — field VALUE shapes, not field config; check whether they are record data (→ open) before closing | +| `driver-sql.zod.ts` | wire | **out of scope** | +| `field-value.zod.ts` | mixed (p) | `LocationValueSchema` — record data, very likely **open**; its sibling `FileValueSchema` is already `z.looseObject` | + +**Authorable strip in `data/`:** +[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#data--open) splits this +directory three ways, and the middle bucket is the one to read: `object`, `driver/memory` +and `field` are **firm** authorable; `external-lookup`, `seed-loader`, `analytics` and +`field-value` are **unresolved** — they still carry `mixed (p)`, so the ledger is saying +"nobody has done the per-schema read" rather than "these are ready". The rest is wire/open +and out of the ruling's forced scope; that count fell by one when #4721 closed +`query.zod.ts`'s `SortNodeSchema`, the one row in this directory where the per-schema read +moved a site OUT of `open` rather than confirming it. + +#### `security/` — remaining strip sites + +| File | Class | Batch | +|---|---|---| +| `explain.zod.ts` | wire | **out of scope** — permission-explain responses; the triage row already says "never strict" | +| `rls.zod.ts` | wire | **out of scope** — `RLSUserContext` / `RLSEvaluationResult` are runtime shapes; the POLICY shape is closed | **Authorable strip in `security/`: 0. This directory is DONE.** -`studio/` has **0 strip of 27** and so has no table here — batch 7 landed, and this -is the confirmation the campaign's own progress log was missing. +`studio/` has **no open sites** and so has no table here — batch 7 landed, and this +is the confirmation the campaign's own progress log was missing. The counts file still +carries a section for it, deliberately: an empty section is a measured zero, while a +missing one is indistinguishable from a directory nobody walked. ## Other directories (coarse; classify per schema before touching) -| Dir | Sites | Dominant class | Rationale | -|---|---|---|---| -| `api/` | 426 | wire | REST/GraphQL request/response contracts — tolerant by design | -| `system/` | 383 | mixed | manifest/datasource blocks are authored; runtime envelopes are wire | -| `kernel/` | 351 | wire | plugin/kernel contracts, code-to-code | -| `cloud/` | 83 | wire | multi-tenant runtime | -| `ai/` | 75 | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | -| `integration/` | 64 | wire | connector payloads — upstream adds fields freely | -| `identity/` | 33 | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified | -| `shared/` | 25 | n/a | utilities and building blocks; strictness decided at the consuming schema | -| `qa/` | 6 | n/a | test fixtures | +Site totals are +[in the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#other-directories-untriaged). +These directories were never gated — the numbers here were hand-copied and +ungated, which is the same failure one level coarser, so they moved with the +rest at #5107. + +| Dir | Dominant class | Rationale | +|---|---|---| +| `api/` | wire | REST/GraphQL request/response contracts — tolerant by design | +| `system/` | mixed | manifest/datasource blocks are authored; runtime envelopes are wire | +| `kernel/` | wire | plugin/kernel contracts, code-to-code | +| `cloud/` | wire | multi-tenant runtime | +| `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | +| `integration/` | wire | connector payloads — upstream adds fields freely | +| `identity/` | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified | +| `shared/` | n/a | utilities and building blocks; strictness decided at the consuming schema | +| `qa/` | n/a | test fixtures | ## Next steps (verify-then-enforce, one shape at a time) @@ -1181,31 +1257,47 @@ the app step's `ACCOUNT_APP.defaultOpen` came from exactly this class of check. ## This file is now machine-checked `pnpm --filter @objectstack/spec check:strictness-ledger` (wired into the Spec -Liveness Check workflow) holds the two claims here that are mechanically -checkable, so this map cannot go stale in silence again: - -- **Site counts.** The method is stated above — `z.object(` or `strictObject(` - occurrences per file — so every number in the triage tables is verifiable. A - count that no longer matches means schemas were added or removed under a - `Class` verdict nobody re-examined. Touching a file forces you back through - this ledger. `strictObject(` had to join the count the moment the helper - existed: counting only `z.object(` would have made every conversion look like - surface *disappearing*, so "this directory got solved" and "this directory got - deleted" would produce the same number. The gate caught that itself on the - first conversion. +Liveness Check workflow) holds the claims here that are mechanically checkable, +so this map cannot go stale in silence again. Since #5107 it holds them from two +directions: + +- **Freshness of the generated counts.** The gate re-derives the whole counts + file from the AST and compares bytes. A schema added, removed or re-postured + under a `Class` verdict nobody re-examined makes the artifact stale, and the + gate says so in exactly those words. That is what the old hand-written counts + bought — touching a file forces you back through this ledger — kept, with the + arithmetic taken away from the human. `strictObject(` had to join the count the + moment the helper existed: counting only `z.object(` would have made every + conversion look like surface *disappearing*, so "this directory got solved" and + "this directory got deleted" would produce the same number. The gate caught + that itself on the first conversion. +- **Consistency of the hand-written rows.** Every row must name a file that + exists and still HAS sites — a row over a file with nothing left to classify is + a verdict about nothing, and the count check used to catch that for free. A + remaining-strip row whose file has reached zero strip sites fails (the reverse + pin). And a `Class` cell in the strip map must parse, because the generated + subtotal is arithmetic over it. - **Coverage.** Every `*.zod.ts` in a triaged directory that HAS sites must have a row. A new one is undeclared surface. The walk is **recursive**; nested files are declared by their path relative to the section directory (`driver/postgres.zod.ts`). Zero-site files (pure enum/token modules like `data/date-macros.zod.ts`) are skipped — there is nothing to classify — and become reportable the day they grow their first `z.object(`. -- **Section totals**, and that any row claiming "strict as of" names a file that - really contains `.strict()`. +- Any row claiming "strict as of" names a file that really contains `.strict()`. -Deliberately NOT checked: the `Class` column. Authorable vs wire vs open is a -judgement about who writes the input, and this campaign's rule is +Deliberately NOT checked: whether the `Class` column is RIGHT. Authorable vs wire +vs open is a judgement about who writes the input, and this campaign's rule is verify-before-tightening. The gate protects the arithmetic and the coverage so -that judgement is always made against current code. +that judgement is always made against current code. What it does now check is the +cell's FORM — a declared verdict, and a declared split when the row is `mixed` or +`split` and no longer provisional — because a subtotal generated from an +unparseable verdict would be published as a confident number, which is this +file's own subject matter. + +**Both directions were proved red before this was believed** (#5107), on the +rule this file keeps re-deriving: hand-patch one number in the counts file and +the freshness half exits 1; delete one hand-written row and the coverage half +exits 1. A green check that has never been shown to go red proves nothing. **What it found on its first run — 11 drifts, in a file being actively edited by the campaign that owns it.** Six counts had moved (`ui/app.zod.ts` 11 → 18, diff --git a/packages/spec/package.json b/packages/spec/package.json index d2c2f56fb2..2eed9e5cfd 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -211,7 +211,8 @@ "check:liveness": "tsx scripts/liveness/check-liveness.mts", "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "check:variant-docs": "tsx scripts/check-variant-docs.mts", - "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", + "gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts", + "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", "check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check", "check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts", diff --git a/packages/spec/scripts/build-strictness-ledger-counts.mts b/packages/spec/scripts/build-strictness-ledger-counts.mts new file mode 100644 index 0000000000..df5328a398 --- /dev/null +++ b/packages/spec/scripts/build-strictness-ledger-counts.mts @@ -0,0 +1,59 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Writes `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` — every + * number the #4001 strictness ledger publishes (#5107). + * + * The ledger's prose merged cleanly all through the campaign; its NUMBERS were + * the whole conflict surface, and they merged in the worst way available — two + * batches each decrement a header by their own correct delta, git merges the rows + * without complaint, and the subtotal line, which overlaps nothing, merges clean + * and wrong. Seven cases in one day. Every correct resolution was the same: + * recompute from the merged tree. + * + * So the numbers are generated, the artifact carries `merge=os-regen` (#4675), + * and recomputation is enforced by the pre-commit half of that driver rather than + * remembered. Regeneration is WHOLESALE — this script never patches a number in + * place, and neither should you. + * + * The verdicts stay hand-written in the ledger. This script reads them (a + * subtotal is arithmetic over a judgement) and never writes to that file. + * + * Usage: + * tsx build-strictness-ledger-counts.mts # rewrite the artifact + * + * Freshness is proved by `check:strictness-ledger`, which renders the same model + * and compares bytes — deliberately not a second parser. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { COUNTS_PATH, loadLedger } from './lib/strictness-ledger-doc'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const SPEC = path.resolve(HERE, '..'); +const REPO = path.resolve(SPEC, '../..'); +const SRC = path.join(SPEC, 'src'); + +const { countsPath, rendered, problems, model } = loadLedger(REPO, SRC); + +fs.writeFileSync(countsPath, rendered); + +console.log(`✓ wrote ${COUNTS_PATH}`); +console.log( + ` ${model.global.sites} site(s) across ${model.global.dirs} triaged director(ies); ` + + `${model.global.strip} strip site(s) in ${model.global.openFiles} file(s).`, +); + +if (problems.length) { + // Written anyway, on purpose: the post-merge regeneration this whole scheme + // rests on must not be blockable by a ledger defect, and `check:` fails on the + // same problems a moment later. What must never happen is a defect becoming an + // invisible zero, so it is counted as `unclassified` and named here. + console.error(`\n⚠ ${problems.length} ledger defect(s) — the artifact records them as \`unclassified\`:\n`); + for (const p of problems) console.error(` ${p}\n`); + console.error(' check:strictness-ledger will fail on these.'); +} diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 2c89b2a1eb..59092bdb09 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -55,6 +55,16 @@ const GATED: ReadonlyArray<{ check: string; gen: string; artifact: string; reads // failure explains itself instead of sending the next reader after a ghost. { check: 'check:api-surface', gen: 'gen:api-surface', artifact: 'api-surface.json', readsDist: true }, { check: 'check:docs', gen: 'gen:docs', artifact: 'content/docs/references/**' }, + // Moved out of NO_GENERATOR at #5107: the strictness ledger's numbers became a + // generated artifact, so this gate now has something to regenerate. It still + // audits source too (a hand-written row must name a live sited file), which is + // why the `gen:` fixes only half of what it can report — the other half is a + // ledger edit, and the failure says which. + { + check: 'check:strictness-ledger', + gen: 'gen:strictness-ledger', + artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md', + }, ]; /** @@ -76,15 +86,13 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ // failing on `main` itself. The doc it checks against is hand-written, so there // is no generator to name. { check: 'check:variant-docs', why: 'audits that each schema variant appears in its hand-written doc — no artifact' }, - // The #4177 story again, one day after #4203 closed it: #4232 added this script - // and nothing in CI runs this reconciliation, so `main` went red for every local - // wrapper run a second time. Caught while wiring `--reconcile-only` into - // lint.yml's unfiltered job — the fix for exactly this class. The ledger it - // audits is a hand-maintained doc (docs/audits/), so there is no generator. - { - check: 'check:strictness-ledger', - why: 'audits the hand-written strictness ledger against the code it describes — no artifact', - }, + // `check:strictness-ledger` used to sit here — "the ledger it audits is a + // hand-maintained doc, so there is no generator". #5107 gave it one (the ledger's + // NUMBERS became an artifact; its VERDICTS stayed hand-written), so it moved to + // GATED above. The story that put it here is still worth keeping: it landed in + // #4232 while nothing in CI ran this reconciliation, so `main` went red for every + // local wrapper run — the second time in three days after #4177 — and the fix was + // wiring `--reconcile-only` into lint.yml's unfiltered job. // The odd one out: it audits the source's TYPES, but reads them from the BUILT // `dist/*.d.ts` — the surface a consumer's import actually resolves to, which // is the only place the defect is visible (#4171). So the `readsDist` caveat diff --git a/packages/spec/scripts/check-strictness-ledger.mts b/packages/spec/scripts/check-strictness-ledger.mts index a5fa94545f..c204f843c7 100644 --- a/packages/spec/scripts/check-strictness-ledger.mts +++ b/packages/spec/scripts/check-strictness-ledger.mts @@ -12,334 +12,299 @@ // - It listed "build the unknown-key WARNING layer" as the next step months after // that layer shipped, sending the next reader off to rebuild it (#4218). // -// A map that drifts is worse than no map: it is followed. This gate holds the two -// claims in it that are mechanically checkable. +// A map that drifts is worse than no map: it is followed. // -// WHAT IT CHECKS -// 1. Site counts. Every object-constructing CALL in the file, read from the AST. -// A count that no longer matches means someone added or removed a schema without -// reclassifying it, and the row's `Class` verdict now covers sites nobody -// triaged. This is the ratchet: touching a file forces you back through the -// ledger. -// The counting used to be a regex over the source text, and the #4001 -// re-measurement found it wrong in BOTH directions on seven files: it counted -// `z.object({…})` written inside JSDoc prose, and it missed the prettier-wrapped -// `z\n .object({` form and `z.looseObject(` entirely. The worst case was -// `automation/time-relative-trigger.zod.ts`, which read as ZERO sites — and a -// zero-site file is deliberately SKIPPED by check 2, so an authorable schema sat -// outside the map while this gate printed "no undeclared schema files". Same -// shape as the non-recursive walk below, one layer further in: not the walk -// blind, but the counter feeding it. See `lib/strictness-ledger.ts`. -// 5. The remaining-strip map. The triage tables say how much surface exists; that -// never answered how much is still OPEN, which is the number batches are planned -// against. Gated in both directions: a file with strip sites must have a row with -// matching counts, AND a row whose file has reached zero strip sites FAILS, so a -// closed file drops out. The reverse pin matters more than the forward one — this -// ledger has already had to record that it once listed a shipped feature as a -// TODO, and a worklist that can outlive its work will. -// 2. Coverage. Every `*.zod.ts` under a triaged directory that HAS `z.object(` sites -// must appear in that directory's table. A new one is undeclared surface — -// exactly what the ledger exists to prevent. The walk is RECURSIVE; nested files -// are declared by their path relative to the section directory -// (`driver/postgres.zod.ts`). It was not recursive at first, and `data/driver/` -// sat undeclared behind that — see the ledger's note on it. Files with zero sites -// (pure enum / token modules like `data/date-macros.zod.ts`) are skipped: the -// ledger classifies sites, and they have none to classify. This is not a hole — -// the day such a file grows its first `z.object(` it becomes undeclared and this -// gate says so. -// 3. Section totals. `### \`ui/\` — 192 sites` must equal the sum of its rows. -// Cheap, and it catches a row edited without updating the header. -// 4. Strictness claims. A row whose note says "strict as of" must name a file that -// actually contains `.strict()` or `strictObject(`. This is deliberately weak — it proves the claim -// is not fiction, not that every site in the file is strict. Rows say things like -// "partially strict"; encoding which sites those are would need a second ledger, -// and the per-schema truth already lives in the code the note points at. +// ── WHAT CHANGED AT #5107, AND WHY THE GATE'S JOB INVERTED ─────────────────── // -// WHAT IT DOES NOT CHECK. The `Class` column itself — authorable vs wire vs open is a -// human judgement about who writes the input, and the campaign's own rule is -// verify-before-tightening. This gate protects the ledger's ARITHMETIC and its -// COVERAGE so that judgement is made against current code. +// The ledger's numbers used to be hand-written and this gate compared them against +// the AST. That worked — it is why every count in the file is trustworthy — but it +// left the numbers in the merge path, and the numbers were the ledger's entire +// conflict surface: two batches each decrement a header by their own correct +// delta, git merges the ROWS cleanly (they do not overlap), and the subtotal, which +// conflicts with nothing, merges CLEAN AND WRONG. Seven cases in one day. +// +// So the numbers moved into a generated artifact carrying `merge=os-regen`, and +// this gate proves two things instead of one: +// +// A. the generated artifact is FRESH — it equals what the AST says right now; +// B. the hand-written prose is CONSISTENT with it — every row names a real file +// with real sites, and every file with sites has a row. +// +// Both directions still fail loudly, which is the property that matters: +// - a stale artifact (someone changed a schema and did not regenerate) → red; +// - a hand row naming a file that is gone, or that has no sites left → red; +// - a file with sites and no row → red (undeclared surface); +// - a strip row whose file has reached ZERO strip sites → red (the reverse pin: +// a worklist that can outlive its work will). +// +// What the count check bought and this must not lose: touching a schema forced you +// back through the ledger to confirm the `Class` verdict still covered it. It still +// does — the artifact goes stale, this gate goes red, and the failure below says so +// in those words. The fix is now `gen:` plus a read of the diff rather than a +// hand-edited number, which is the point: the arithmetic was never the part a human +// was good at, and it is where all seven of the day's bad merges landed. +// +// WHAT IT STILL DOES NOT CHECK. The `Class` column's correctness — authorable vs +// wire vs open is a human judgement about who writes the input, and the campaign's +// rule is verify-before-tightening. The gate protects the ARITHMETIC and the +// COVERAGE so that judgement is always made against current code. It does now check +// the `Class` cell's FORM (that it is a declared verdict, and that a resolved +// `mixed`/`split` states its split), because the generated subtotal is arithmetic +// over that cell — an unparseable verdict would otherwise be published as a +// confident number, which is this file's own subject matter. // // Usage: // tsx check-strictness-ledger.mts # fail on drift -// tsx check-strictness-ledger.mts --list # print the parsed ledger +// tsx check-strictness-ledger.mts --list # print the parsed rows and their buckets import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; import { analyzeSites, countSites, countStripSites, listSchemaFiles } from './lib/strictness-ledger'; +import { + BUCKETS, + COUNTS_PATH, + GEN_COMMAND, + LEDGER_PATH, + VERDICTS, + bucketize, + loadLedger, + parseClassCell, +} from './lib/strictness-ledger-doc'; const HERE = path.dirname(url.fileURLToPath(import.meta.url)); const SPEC = path.resolve(HERE, '..'); const REPO = path.resolve(SPEC, '../..'); -const LEDGER = path.join(REPO, 'docs/audits/2026-07-unknown-key-strictness-ledger.md'); const SRC = path.join(SPEC, 'src'); const LIST = process.argv.includes('--list'); -interface Row { - dir: string; - files: string[]; - /** Declared `z.object(` count per file, index-aligned with `files`. */ - counts: number[]; - note: string; - line: number; -} +const { parsed, model, problems, rendered, countsPath, ledgerText } = loadLedger(REPO, SRC); -/** - * Parse `8`, `6+2`, `10+9+2` or `4 ea` against a file list. - * `N ea` means every file in the row declares N — the ledger's shorthand for a - * run of same-sized files. - */ -function parseCounts(cell: string, fileCount: number): number[] | null { - const ea = cell.match(/^(\d+)\s*ea$/); - if (ea) return Array(fileCount).fill(Number(ea[1])); - const parts = cell.split('+').map((p) => p.trim()); - if (!parts.every((p) => /^\d+$/.test(p))) return null; - const nums = parts.map(Number); - if (nums.length === 1 && fileCount > 1) return null; - return nums.length === fileCount ? nums : null; +if (LIST) { + for (const t of model.triaged) { + console.log(`${t.dir}/ — ${t.sites} sites, ${t.strip} strip`); + for (const f of t.files) console.log(` ${f.file} sites=${f.sites} strip=${f.strip}`); + for (const b of BUCKETS) if (t.buckets[b]) console.log(` bucket ${b} = ${t.buckets[b]}`); + } + process.exit(0); } -const md = fs.readFileSync(LEDGER, 'utf-8').split('\n'); -const rows: Row[] = []; -const sectionTotals = new Map(); -let dir: string | null = null; - -for (let i = 0; i < md.length; i++) { - const line = md[i]; - - const header = line.match(/^### `([a-z-]+)\/` — (\d+) sites/); - if (header) { - dir = header[1]; - sectionTotals.set(dir, { declared: Number(header[2]), line: i + 1 }); - continue; - } - // Any other h2/h3 closes the current section, so prose tables below the - // triage cannot be mistaken for rows. - if (/^##/.test(line) && !header) dir = null; - if (!dir || !line.startsWith('|')) continue; +const errors: string[] = [...problems]; - const cells = line.split('|').slice(1, -1).map((c) => c.trim()); - if (cells.length < 3) continue; - const files = [...cells[0].matchAll(/`([^`]+\.zod\.ts)`/g)].map((m) => m[1]); - if (files.length === 0) continue; +/* ── A. the generated artifact is fresh ────────────────────────────────────── */ +// +// Byte comparison against a re-render, not a re-parse of the artifact. A parser +// reading it back would be a second implementation of the same truth, and when two +// implementations disagree the one that wins is whichever the gate happens to +// call — which is how a green check ends up standing over a wrong file. This +// campaign has that scar in four separate instruments already. - const counts = parseCounts(cells[1], files.length); - if (counts === null) { - console.error(`✗ ${LEDGER}:${i + 1} — cannot parse site count "${cells[1]}" for ${files.length} file(s)`); - process.exit(1); +/** The first few differing lines, as `- on disk` / `+ expected`. */ +function firstDifferences(actual: string, expected: string, limit = 6): string { + const a = actual.split('\n'); + const b = expected.split('\n'); + const out: string[] = []; + for (let i = 0; i < Math.max(a.length, b.length) && out.length < limit * 2; i++) { + if (a[i] === b[i]) continue; + if (a[i] !== undefined) out.push(` - ${a[i]}`); + if (b[i] !== undefined) out.push(` + ${b[i]}`); } - rows.push({ dir, files, counts, note: cells.slice(2).join(' '), line: i + 1 }); + return out.length ? ` first difference(s) (- on disk, + expected):\n${out.join('\n')}\n` : ''; } -if (LIST) { - for (const r of rows) { - console.log(`${r.dir}/ ${r.files.map((f, i) => `${f}=${r.counts[i]}`).join(' ')}`); +if (!fs.existsSync(countsPath)) { + errors.push(`${COUNTS_PATH} is MISSING.\n → ${GEN_COMMAND}`); +} else { + const onDisk = fs.readFileSync(countsPath, 'utf-8'); + if (onDisk !== rendered) { + errors.push( + `${COUNTS_PATH} is STALE — it does not match what the AST says right now.\n` + + ` → ${GEN_COMMAND}\n` + + ` Then READ the diff: a count that moved means a schema was added, removed or\n` + + ` re-postured under a \`Class\` verdict nobody re-examined. Confirm the verdict in\n` + + ` ${LEDGER_PATH} still covers it — that re-read is what\n` + + ` the old hand-written counts bought, and it is the half of them worth keeping.\n` + + ` ⛔ Never hand-patch a number in the artifact. Regeneration is wholesale.\n` + + firstDifferences(onDisk, rendered), + ); } - process.exit(0); } -const errors: string[] = []; -const declaredByDir = new Map>(); +/* ── B. the hand-written prose is consistent with the code ─────────────────── */ -for (const row of rows) { +// B1. Triage rows — every declared file must exist AND still have sites. +// +// The second half is the reverse pin the per-file count used to provide for free: +// a row over a file with nothing left to classify is a verdict about nothing, and +// it reads to the next batch as surface that was triaged. +const ledgerLines = ledgerText.split('\n'); +const declaredByDir = new Map>(); +for (const row of parsed.triage) { const declared = declaredByDir.get(row.dir) ?? new Set(); declaredByDir.set(row.dir, declared); - row.files.forEach((file, idx) => { + for (const file of row.files) { declared.add(file); const abs = path.join(SRC, row.dir, file); if (!fs.existsSync(abs)) { errors.push( `ledger:${row.line} — lists \`${row.dir}/${file}\`, which does not exist.\n` + - ` → the file moved or was deleted; update or drop the row.`, + ` → the file moved or was deleted; update or drop the row.`, ); - return; + continue; } - const actual = countSites(abs); - if (actual !== row.counts[idx]) { + if (countSites(abs) === 0) { errors.push( - `ledger:${row.line} — \`${row.dir}/${file}\` declares ${row.counts[idx]} site(s), found ${actual}.\n` + - ` → ${actual > row.counts[idx] ? 'new' : 'removed'} \`z.object(\` site(s) since the row was written. ` + - `Re-read the file, confirm the Class verdict still covers it, and update the count.`, + `ledger:${row.line} — \`${row.dir}/${file}\` has NO object sites, but still has a triage row.\n` + + ` → this ledger classifies SITES and this file has none left to classify (a pure\n` + + ` enum/token module is deliberately never declared). Drop the row — and check\n` + + ` whether the sites moved to a file that now needs one.`, ); } - }); + } - if (/strict as of/i.test(row.note)) { - const anyStrict = row.files.some((f) => { - const abs = path.join(SRC, row.dir, f); - if (!fs.existsSync(abs)) return false; - // `strictObject(` counts as strictness, same as a literal `.strict()` — - // the helper applies it. Matching only `.strict()` made a converted file - // read as NOT strict, so this check called a true claim a lie. Same blind - // spot as the site count had: the measuring tool has to learn the idiom - // whenever the idiom changes. - return /\.strict\(\)|(? { + const abs = path.join(SRC, row.dir, f); + if (!fs.existsSync(abs)) return false; + // `strictObject(` counts as strictness, same as a literal `.strict()` — the + // helper applies it. Matching only `.strict()` made a converted file read as + // NOT strict, so this check once called a true claim a lie. + return /\.strict\(\)|(? `${row.dir}/${f}`).join(', ')}\n` + ` → the claim is stale; the ratchet was reverted or the schema moved.`, - ); - } + ); } } -// Coverage ratchet — a new .zod.ts in a triaged directory is undeclared surface. +// B3. Triage coverage ratchet — a new .zod.ts in a triaged directory is undeclared +// surface. The walk is RECURSIVE; see lib/strictness-ledger.ts for why that is +// load-bearing (`data/driver/` sat outside the map behind a one-level walk while +// this gate printed "no undeclared schema files"). for (const [d, declared] of declaredByDir) { const dirPath = path.join(SRC, d); if (!fs.existsSync(dirPath)) continue; - // Recursive — see lib/strictness-ledger.ts for why that is load-bearing. - // Nested files are declared by their path relative to the section directory - // (`driver/postgres.zod.ts`), which the row parser already accepts. - const onDisk = listSchemaFiles(dirPath); - // Zero-site files carry nothing to classify (see the header note). They become - // reportable the moment they grow a `z.object(`. - const missing = onDisk.filter((f) => !declared.has(f) && countSites(path.join(dirPath, f)) > 0); + // Zero-site files carry nothing to classify. They become reportable the moment + // they grow a `z.object(`. + const missing = listSchemaFiles(dirPath).filter((f) => !declared.has(f) && countSites(path.join(dirPath, f)) > 0); if (missing.length) { errors.push( `\`${d}/\` has ${missing.length} undeclared schema file(s) with sites: ` + - `${missing.map((f) => `${f} (${countSites(path.join(dirPath, f))})`).join(', ')}\n` + - ` → add a row to the ${d}/ table with its site count and a Class verdict ` + - `(authorable / wire / open — see the classification rule at the top of the ledger).`, + `${missing.map((f) => `${f} (${countSites(path.join(dirPath, f))})`).join(', ')}\n` + + ` → add a row to the ${d}/ triage table with a Class verdict (authorable / wire /\n` + + ` open / no door / no gate — see the classification rule at the top of the ledger).\n` + + ` The site COUNT is generated; do not write one into the row.`, ); } } -// ── The remaining-strip-site map (#4001 re-measurement) ───────────────────── -// -// The triage tables above say how much surface exists; they never said how much -// of it is still OPEN. That number is what every batch plan is scheduled -// against, and until it was measured the campaign was planning off counts of -// `strictObject(` occurrences — which miss every schema closed with the older -// `z.object(…).strict()` idiom, reading `automation/` as 0 strict when it has 8. +// B4. The remaining-strip map, gated in BOTH directions. // -// So it is a table now, and gated in BOTH directions: -// forward — a file with strip sites must have a row with the right counts; +// forward — a file with strip sites must have a row; // reverse — a row whose file has reached zero strip sites FAILS. // // The reverse pin is the important half. A worklist that can outlive its work // will: this ledger has already had to record that it once listed a shipped // feature as a TODO, and the ADR-0010 debt list needed the same pin for the same // reason. A row that cannot survive its own completion cannot rot. -interface StripRow { dir: string; file: string; strip: number; total: number; line: number } -const stripRows: StripRow[] = []; -const stripTotals = new Map(); -let sdir: string | null = null; - -for (let i = 0; i < md.length; i++) { - const line = md[i]; - const header = line.match(/^#### `([a-z-]+)\/` — (\d+) strip of (\d+)/); - if (header) { - sdir = header[1]; - stripTotals.set(sdir, { strip: Number(header[2]), total: Number(header[3]), line: i + 1 }); - continue; - } - if (/^#{2,4} /.test(line) && !header) sdir = null; - if (!sdir || !line.startsWith('|')) continue; - const cells = line.split('|').slice(1, -1).map((c) => c.trim()); - if (cells.length < 4) continue; - const file = cells[0].match(/^`([^`]+\.zod\.ts)`$/)?.[1]; - if (!file || !/^\d+$/.test(cells[1]) || !/^\d+$/.test(cells[2])) continue; - stripRows.push({ dir: sdir, file, strip: Number(cells[1]), total: Number(cells[2]), line: i + 1 }); -} - -const stripDeclared = new Map>(); -for (const row of stripRows) { - const perDir = stripDeclared.get(row.dir) ?? new Map(); +const stripDeclared = new Map>(); +for (const row of parsed.strip) { + const perDir = stripDeclared.get(row.dir) ?? new Set(); stripDeclared.set(row.dir, perDir); - perDir.set(row.file, row); + perDir.add(row.file); const abs = path.join(SRC, row.dir, row.file); if (!fs.existsSync(abs)) { errors.push( `ledger:${row.line} — remaining-strip map lists \`${row.dir}/${row.file}\`, which does not exist.\n` + - ` → the file moved or was deleted; drop the row.`, + ` → the file moved or was deleted; drop the row.`, ); continue; } - const sites = analyzeSites(abs); - const strip = sites.filter((s) => s.posture === 'strip').length; + const strip = analyzeSites(abs).filter((s) => s.posture === 'strip').length; if (strip === 0) { errors.push( `ledger:${row.line} — \`${row.dir}/${row.file}\` has NO strip sites left, but still has a row in the\n` + - ` remaining-strip map.\n` + - ` → the file is CLOSED: delete the row and decrement the "${row.dir}/ — N strip of M" header.\n` + - ` This table is a worklist; a row that outlives its work is how this ledger rotted before.`, + ` remaining-strip map.\n` + + ` → the file is CLOSED: delete the row. The header and the subtotal are generated and\n` + + ` will follow — do not adjust a number by hand.\n` + + ` This table is a worklist; a row that outlives its work is how this ledger rotted before.`, ); - } else if (strip !== row.strip || sites.length !== row.total) { + continue; + } + + // The Class cell is a machine-readable input to the generated subtotal, so its + // FORM is gated here (never its verdict). + const parsedClass = parseClassCell(row.classCell); + if (!parsedClass) { errors.push( - `ledger:${row.line} — \`${row.dir}/${row.file}\` declares ${row.strip} strip of ${row.total}, found ${strip} of ${sites.length}.\n` + - ` → ${strip < row.strip ? 'sites were closed' : 'sites were opened or added'}. Update the row (and the section header),\n` + - ` and confirm the Class verdict still covers what is left.`, + `ledger:${row.line} — \`${row.dir}/${row.file}\`'s Class cell "${row.classCell}" is not a verdict.\n` + + ` → one of: ${VERDICTS.join(', ')}; optionally \` (p)\`; optionally a split\n` + + ` (\`mixed · 6 authorable, 2 wire\`). The subtotal is generated from this cell.`, ); + } else { + const res = bucketize(parsedClass, strip); + if ('error' in res) errors.push(`ledger:${row.line} — \`${row.dir}/${row.file}\` ${res.error}`); } } for (const [d, perDir] of stripDeclared) { const dirPath = path.join(SRC, d); if (!fs.existsSync(dirPath)) continue; - const missing = listSchemaFiles(dirPath) - .filter((f) => !perDir.has(f) && countStripSites(path.join(dirPath, f)) > 0); + const missing = listSchemaFiles(dirPath).filter( + (f) => !perDir.has(f) && countStripSites(path.join(dirPath, f)) > 0, + ); if (missing.length) { errors.push( `\`${d}/\` has ${missing.length} file(s) with strip sites missing from the remaining-strip map: ` + - `${missing.map((f) => `${f} (${countStripSites(path.join(dirPath, f))})`).join(', ')}\n` + - ` → add a row under \`#### \\\`${d}/\\\`\` with its strip/total counts and a per-schema Class verdict.`, + `${missing.map((f) => `${f} (${countStripSites(path.join(dirPath, f))})`).join(', ')}\n` + + ` → add a row under \`#### \\\`${d}/\\\` — remaining strip sites\` with a per-schema Class\n` + + ` verdict. The strip/site COUNTS are generated; do not write them into the row.`, ); } } -for (const [d, { strip, total, line }] of stripTotals) { - const rows = stripRows.filter((r) => r.dir === d); - const sumStrip = rows.reduce((a, r) => a + r.strip, 0); - const dirPath = path.join(SRC, d); - const actualTotal = fs.existsSync(dirPath) - ? listSchemaFiles(dirPath).reduce((a, f) => a + countSites(path.join(dirPath, f)), 0) - : 0; - if (sumStrip !== strip) { - errors.push( - `ledger:${line} — \`${d}/\` remaining-strip header says ${strip} strip, rows sum to ${sumStrip}.\n` + - ` → update the header to match the rows.`, - ); - } - if (total !== actualTotal) { +// B5. A triaged directory with open files must carry a remaining-strip section. +// Without this the whole section could vanish and the forward pin above would +// never run for it — "0 strip" and "nobody looked" must not read the same. +for (const dir of parsed.triagedDirs) { + const t = model.triaged.find((x) => x.dir === dir); + if (t && t.openFiles.length > 0 && !parsed.stripDirs.includes(dir)) { errors.push( - `ledger:${line} — \`${d}/\` remaining-strip header says "of ${total}", the directory has ${actualTotal} sites.\n` + - ` → update the header; it must match the triage section total for the same directory.`, + `\`${dir}/\` has ${t.openFiles.length} file(s) with strip sites but no remaining-strip section.\n` + + ` → add \`#### \\\`${dir}/\\\` — remaining strip sites\` with a row per open file.`, ); } } -// Section arithmetic. -for (const [d, { declared, line }] of sectionTotals) { - const sum = rows.filter((r) => r.dir === d).reduce((a, r) => a + r.counts.reduce((x, y) => x + y, 0), 0); - if (sum !== declared) { - errors.push( - `ledger:${line} — \`${d}/\` header says ${declared} sites, rows sum to ${sum}.\n` + - ` → update the header to match the rows.`, - ); - } -} +/* ── report ───────────────────────────────────────────────────────────────── */ if (errors.length) { console.error(`\n✗ strictness ledger: ${errors.length} drift(s)\n`); for (const e of errors) console.error(` ${e}\n`); - console.error(` The ledger is ${path.relative(REPO, LEDGER)}.\n`); + console.error(` The ledger is ${LEDGER_PATH} (prose — hand-written).`); + console.error(` The counts are ${COUNTS_PATH} (generated — ${GEN_COMMAND}).\n`); process.exit(1); } -const fileCount = rows.reduce((a, r) => a + r.files.length, 0); -const openStrip = stripRows.reduce((a, r) => a + r.strip, 0); +const triageFiles = parsed.triage.reduce((a, r) => a + r.files.length, 0); +console.log( + `✓ strictness ledger: ${triageFiles} file(s) across ${parsed.triagedDirs.length} triaged director(ies) — ` + + `every row names a live sited file, no undeclared schema files.`, +); console.log( - `✓ strictness ledger: ${fileCount} file(s) across ${sectionTotals.size} triaged director(ies) — ` + - `site counts match, no undeclared schema files, section totals balance.`, + `✓ remaining-strip map: ${parsed.strip.length} open file(s) / ${model.global.strip} strip site(s) across ` + + `${parsed.stripDirs.length} director(ies) — no file with strip sites is missing a row, ` + + `no closed file still carries one, every Class cell resolves.`, ); console.log( - `✓ remaining-strip map: ${stripRows.length} open file(s) / ${openStrip} strip site(s) across ` + - `${stripTotals.size} director(ies) — counts match, no file with strip sites is missing a row, ` + - `no closed file still carries one.`, + `✓ ${COUNTS_PATH} is current — ${model.global.sites} site(s) measured, ` + + `${model.global.buckets.authorable} authorable strip site(s) left.`, ); diff --git a/packages/spec/scripts/lib/strictness-ledger-doc.ts b/packages/spec/scripts/lib/strictness-ledger-doc.ts new file mode 100644 index 0000000000..129eae2353 --- /dev/null +++ b/packages/spec/scripts/lib/strictness-ledger-doc.ts @@ -0,0 +1,643 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The strictness ledger's **numbers/prose split** (#5107). + * + * ## Why this module exists + * + * `docs/audits/2026-07-unknown-key-strictness-ledger.md` was the hottest + * merge-conflict file in the repo during the #4001 campaign, and every conflict + * landed in the same place: its NUMBERS. Per-file counts, section headers, + * authorable subtotals. The prose — `Class` verdicts, evidence, exemption + * rationales — merged cleanly all day. + * + * The numbers merged badly in the worst possible way. Two batches each delete + * their own rows and decrement the header by their own delta; git merges the ROWS + * cleanly (they do not overlap) and the subtotal line, which conflicts with + * nothing, **merges clean and wrong**. Neither side made a mistake — each number + * was right against the branch that computed it. Seven such cases in a single day + * (2026-08-03/04): the `ui/` subtotal alone was written as 119, 110 and 100 by + * three batches whose merge is 91, a number no branch ever wrote down. + * + * So the numbers stop being written by hand. They are a pure function of the AST + * (plus, for the bucket split, the hand-written `Class` column), which means the + * correct resolution of every one of those conflicts was always the same three + * words: **recompute from the merged tree**. That is exactly what the + * `merge=os-regen` driver (#4675) already automates for `packages/spec`'s + * generated artifacts, so the counts move into an artifact it owns. + * + * ## What stays hand-written, and why it must + * + * The `Class` verdict is a *judgement about who writes this schema's input*. No + * AST can produce it — the reverse pin cannot tell "finished, the rest is wire by + * decision" from "nobody got to it", and only that column can. The evidence and + * the findings log are load-bearing prose. They stay in the ledger, and this + * module never writes to that file. + * + * ## The one place the two meet + * + * The authorable subtotal is arithmetic (generated) over a judgement + * (hand-written), so the judgement has to be *machine-readable* — hence the + * `Class` cell grammar below. It is deliberately strict: an unrecognised verdict + * is a hard failure rather than a silent zero, and a `mixed`/`split` row that + * resolves must state its own split rather than leaving the generator to guess. + * A tolerant parser here would put the campaign's numbers back exactly where + * #5107 took them from — quietly wrong, in a green file. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { analyzeSites, listSchemaFiles, type Posture } from './strictness-ledger'; + +/* ------------------------------------------------------------------ verdicts */ + +/** + * The `Class` vocabulary the ledger uses. Every one of these was introduced by a + * measurement that needed it, and two of them (`no door` / `no gate`) imply + * OPPOSITE follow-ups — retiring a `no gate` shape deletes something authors use. + * So the parser rejects anything outside this set rather than bucketing it as + * "other". + */ +export const VERDICTS = [ + 'authorable', + 'verify', + 'mixed', + 'split', + 'wire', + 'open', + 'no door', + 'no gate', +] as const; +export type Verdict = (typeof VERDICTS)[number]; + +/** The buckets the subtotals report. */ +export const BUCKETS = ['authorable', 'unresolved', 'wire/open', 'no door', 'no gate', 'unclassified'] as const; +export type Bucket = (typeof BUCKETS)[number]; + +/** + * Verdict → bucket, for the verdicts that name exactly one class. + * + * `verify` counts as **authorable**: `ui/app.zod.ts`'s `BaseNavItemSchema` is held + * pending the finding-16 `.extend()` check, which is a readiness flag on an + * authorable site, not a third class — and the ledger's published subtotal + * (`view` 6 + `app` 1 = 7) already counted it that way. + * + * `mixed` / `split` name no single class and are resolved separately: with an + * explicit breakdown when the per-schema read has been done, or as `unresolved` + * while the row still carries `(p)`. + */ +const BUCKET_OF: Partial> = { + authorable: 'authorable', + verify: 'authorable', + wire: 'wire/open', + open: 'wire/open', + 'no door': 'no door', + 'no gate': 'no gate', +}; + +export interface ParsedClass { + verdict: Verdict; + /** The `(p)` flag — a provisional call made from exports/JSDoc, not a full read. */ + provisional: boolean; + /** Explicit per-class split (`· 6 authorable`), or `null` when the row states none. */ + breakdown: ReadonlyArray<{ n: number; verdict: Verdict }> | null; +} + +/** + * Parse a `Class` cell. + * + * Grammar (markdown emphasis, strikethrough and backticks are stripped first, so + * `**no door**` and `no door` are the same cell): + * + * [(p)] [· [, ]…] + * + * Returns `null` for a cell that does not parse — the caller reports it as a + * ledger defect naming the vocabulary, which is more useful than a guess. + */ +export function parseClassCell(raw: string): ParsedClass | null { + const text = raw + .replace(/~~/g, '') + .replace(/\*\*/g, '') + .replace(/`/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return null; + + const [head, tail] = splitOnce(text, '·'); + const m = /^(.+?)(\s*\(p\))?$/.exec(head.trim()); + if (!m) return null; + const verdict = m[1].trim().toLowerCase(); + if (!isVerdict(verdict)) return null; + + let breakdown: Array<{ n: number; verdict: Verdict }> | null = null; + if (tail !== null) { + breakdown = []; + for (const part of tail.split(',')) { + const b = /^\s*(\d+)\s+(.+?)\s*$/.exec(part); + if (!b) return null; + const v = b[2].toLowerCase(); + if (!isVerdict(v)) return null; + breakdown.push({ n: Number(b[1]), verdict: v }); + } + if (!breakdown.length) return null; + } + + return { verdict, provisional: Boolean(m[2]), breakdown }; +} + +function isVerdict(s: string): s is Verdict { + return (VERDICTS as readonly string[]).includes(s); +} + +function splitOnce(s: string, sep: string): [string, string | null] { + const i = s.indexOf(sep); + return i === -1 ? [s, null] : [s.slice(0, i), s.slice(i + sep.length)]; +} + +/** + * Attribute a row's strip sites to buckets, or explain why it cannot be done. + * + * The three ways a row resolves, in the order the ledger actually uses them: + * - a single-class verdict → the whole row lands in that bucket; + * - `mixed`/`split` **with** a breakdown → exactly as declared, and the + * breakdown must sum to the row's strip count (a declaration that does not + * add up is a defect, not a rounding); + * - `mixed`/`split` still marked `(p)` → `unresolved`, which is the honest + * answer and is what `data/`'s "~33 needing a per-schema verdict" is made of. + * + * A resolved `mixed`/`split` with no breakdown is refused. That is the case where + * a guess would be invisible: the row looks finished, and whatever the generator + * assumed would appear as a confident subtotal. + */ +export function bucketize( + parsed: ParsedClass, + strip: number, +): { buckets: Record } | { error: string } { + const buckets = emptyBuckets(); + + if (parsed.breakdown) { + const sum = parsed.breakdown.reduce((a, b) => a + b.n, 0); + if (sum !== strip) { + return { + error: + `its Class breakdown sums to ${sum} but the file has ${strip} strip site(s).\n` + + ` → the breakdown must account for every strip site in the row, and nothing else.`, + }; + } + for (const b of parsed.breakdown) { + const bucket = BUCKET_OF[b.verdict]; + if (!bucket) { + return { + error: + `its Class breakdown uses \`${b.verdict}\`, which names no single class.\n` + + ` → break it down into the classes it mixes instead.`, + }; + } + buckets[bucket] += b.n; + } + return { buckets }; + } + + const bucket = BUCKET_OF[parsed.verdict]; + if (bucket) { + buckets[bucket] += strip; + return { buckets }; + } + + // `mixed` / `split` with no breakdown. + if (parsed.provisional) { + buckets.unresolved += strip; + return { buckets }; + } + return { + error: + `is \`${parsed.verdict}\` with no split declared, and no longer carries \`(p)\`.\n` + + ` → state the split in the Class cell (e.g. \`${parsed.verdict} · 6 authorable, 2 wire\`),\n` + + ` or put \`(p)\` back if the per-schema read has not been done. The subtotal is\n` + + ` generated from this cell, so an undeclared split would be published as a guess.`, + }; +} + +export function emptyBuckets(): Record { + return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 }; +} + +/* -------------------------------------------------------------- ledger parse */ + +export interface TriageRow { + dir: string; + files: string[]; + classCell: string; + /** 1-based line in the ledger. */ + line: number; +} + +export interface StripRow { + dir: string; + file: string; + classCell: string; + line: number; +} + +export interface ParsedLedger { + /** Triaged directories, in the order the ledger sections them. */ + triagedDirs: string[]; + triage: TriageRow[]; + /** Directories carrying a remaining-strip section, in ledger order. */ + stripDirs: string[]; + strip: StripRow[]; +} + +const TRIAGE_HEADER = /^### `([a-z-]+)\/` — file-level triage\s*$/; +const STRIP_HEADER = /^#### `([a-z-]+)\/` — remaining strip sites\s*$/; + +/** + * Read the hand-written ledger into rows. + * + * The section headers carry no numbers any more, which is the point: they were + * the "merged clean and wrong" surface, and as a side effect their anchors are + * now stable instead of changing on every batch. + */ +export function parseLedger(md: string): ParsedLedger { + const lines = md.split('\n'); + const out: ParsedLedger = { triagedDirs: [], triage: [], stripDirs: [], strip: [] }; + let mode: 'triage' | 'strip' | null = null; + let dir: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (/^#{1,6} /.test(line)) { + // Any heading closes the current section; only the two shapes below open one. + mode = null; + dir = null; + const t = TRIAGE_HEADER.exec(line); + if (t) { + mode = 'triage'; + dir = t[1]; + if (!out.triagedDirs.includes(dir)) out.triagedDirs.push(dir); + continue; + } + const s = STRIP_HEADER.exec(line); + if (s) { + mode = 'strip'; + dir = s[1]; + if (!out.stripDirs.includes(dir)) out.stripDirs.push(dir); + } + continue; + } + + if (!mode || !dir || !line.startsWith('|')) continue; + const cells = line.split('|').slice(1, -1).map((c) => c.trim()); + if (cells.length < 3) continue; + + if (mode === 'triage') { + const files = [...cells[0].matchAll(/`([^`]+\.zod\.ts)`/g)].map((m) => m[1]); + if (!files.length) continue; + out.triage.push({ dir, files, classCell: cells[1], line: i + 1 }); + } else { + const file = /^`([^`]+\.zod\.ts)`$/.exec(cells[0])?.[1]; + if (!file) continue; + out.strip.push({ dir, file, classCell: cells[1], line: i + 1 }); + } + } + return out; +} + +/* --------------------------------------------------------------- counts model */ + +export interface FileCount { + file: string; + sites: number; + strip: number; +} + +export interface TriagedDirCounts { + dir: string; + files: FileCount[]; + sites: number; + strip: number; + posture: Record; + /** Files with at least one strip site, in path order. */ + openFiles: FileCount[]; + buckets: Record; +} + +export interface CountsModel { + triaged: TriagedDirCounts[]; + other: { dir: string; sites: number }[]; + global: { + sites: number; + strip: number; + openFiles: number; + dirs: number; + posture: Record; + buckets: Record; + }; +} + +/** Per-file AST reading for one directory, recursively, in path order. */ +function measureDir(srcDir: string, dir: string): { files: FileCount[]; posture: Record } { + const dirPath = path.join(srcDir, dir); + const posture: Record = { strict: 0, passthrough: 0, catchall: 0, strip: 0 }; + const files: FileCount[] = []; + for (const rel of listSchemaFiles(dirPath)) { + const sites = analyzeSites(path.join(dirPath, rel), rel); + for (const s of sites) posture[s.posture]++; + const strip = sites.filter((s) => s.posture === 'strip').length; + if (sites.length) files.push({ file: rel, sites: sites.length, strip }); + } + return { files, posture }; +} + +/** + * Every number the ledger publishes, computed from the AST — plus the bucket + * split, which is the one place a hand-written verdict is an input. + * + * Rows with a `Class` cell that cannot be resolved are reported in `problems` and + * their sites land in the `unclassified` bucket. The artifact is still written: + * refusing would break the post-merge regeneration the whole scheme rests on, and + * a visible `unclassified` count plus a red `check:strictness-ledger` is a louder + * signal than a generator that will not run. + */ +export function buildCounts( + srcDir: string, + ledger: ParsedLedger, + existsDir: (dir: string) => boolean, + allDirs: string[], +): { model: CountsModel; problems: string[] } { + const problems: string[] = []; + const byFile = new Map(); + for (const row of ledger.strip) byFile.set(`${row.dir}/${row.file}`, row); + + const triaged: TriagedDirCounts[] = []; + for (const dir of ledger.triagedDirs) { + if (!existsDir(dir)) { + problems.push(`the ledger triages \`${dir}/\`, which does not exist under packages/spec/src.`); + continue; + } + const { files, posture } = measureDir(srcDir, dir); + const openFiles = files.filter((f) => f.strip > 0); + const buckets = emptyBuckets(); + + for (const f of openFiles) { + const row = byFile.get(`${dir}/${f.file}`); + if (!row) { + // The coverage half of the gate reports this properly; here it only has + // to not become a silent zero. + buckets.unclassified += f.strip; + continue; + } + const parsed = parseClassCell(row.classCell); + if (!parsed) { + problems.push( + `ledger:${row.line} — \`${dir}/${f.file}\`'s Class cell "${row.classCell}" is not a verdict.\n` + + ` → use one of: ${VERDICTS.join(', ')} (optionally \` (p)\`, optionally \` · N \`).`, + ); + buckets.unclassified += f.strip; + continue; + } + const res = bucketize(parsed, f.strip); + if ('error' in res) { + problems.push(`ledger:${row.line} — \`${dir}/${f.file}\` ${res.error}`); + buckets.unclassified += f.strip; + continue; + } + for (const b of BUCKETS) buckets[b] += res.buckets[b]; + } + + triaged.push({ + dir, + files, + sites: files.reduce((a, f) => a + f.sites, 0), + strip: files.reduce((a, f) => a + f.strip, 0), + posture, + openFiles, + buckets, + }); + } + + const other = allDirs + .filter((d) => !ledger.triagedDirs.includes(d)) + .map((dir) => ({ dir, sites: measureDir(srcDir, dir).files.reduce((a, f) => a + f.sites, 0) })) + .filter((d) => d.sites > 0); + + const globalPosture: Record = { strict: 0, passthrough: 0, catchall: 0, strip: 0 }; + const globalBuckets = emptyBuckets(); + for (const t of triaged) { + for (const p of Object.keys(globalPosture) as Posture[]) globalPosture[p] += t.posture[p]; + for (const b of BUCKETS) globalBuckets[b] += t.buckets[b]; + } + + return { + model: { + triaged, + other, + global: { + sites: triaged.reduce((a, t) => a + t.sites, 0), + strip: triaged.reduce((a, t) => a + t.strip, 0), + openFiles: triaged.reduce((a, t) => a + t.openFiles.length, 0), + dirs: triaged.length, + posture: globalPosture, + buckets: globalBuckets, + }, + }, + problems, + }; +} + +/* ------------------------------------------------------------------ rendering */ + +/** Where the generated artifact lives, relative to the repo root. */ +export const COUNTS_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md'; + +/** The ledger it belongs to, relative to the repo root. */ +export const LEDGER_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.md'; + +/** The command that rewrites the artifact. */ +export const GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:strictness-ledger'; + +const BUCKET_LABEL: Record = { + authorable: 'authorable — the ruling\'s forced scope', + unresolved: 'unresolved — needs a per-schema verdict', + 'wire/open': 'wire / open — out of forced scope', + 'no door': 'no door — no carrier, ADR-0049 territory', + 'no gate': 'no gate — carrier live, no parse', + unclassified: '⚠️ unclassified — no ledger row', +}; + +/** + * Render the artifact. Pure function of the model, so `check:strictness-ledger` + * proves freshness by rendering and comparing bytes — there is no second parser + * to disagree with this writer. + */ +export function renderCounts(model: CountsModel): string { + const L: string[] = []; + const g = model.global; + + L.push(''); + L.push(``); + L.push(''); + L.push('# Unknown-key strictness ledger — the counts (generated)'); + L.push(''); + L.push('Every number the #4001 strictness ledger publishes, computed from the AST'); + L.push(`(\`packages/spec/scripts/lib/strictness-ledger.ts\`). The verdicts, the evidence and`); + L.push(`the exemption rationales live in [the ledger itself](./${path.basename(LEDGER_PATH)}) and`); + L.push('are hand-written; **this file has no prose to preserve** and is regenerated whole.'); + L.push(''); + L.push('Split out at #5107. These numbers were the ledger\'s entire merge-conflict surface:'); + L.push('two batches each decrement a header by their own delta, git merges the rows cleanly,'); + L.push('and the subtotal — which conflicts with nothing — merges clean and wrong. Seven cases'); + L.push('in one day. The correct resolution was always "recompute from the merged tree", so the'); + L.push('path carries `merge=os-regen` (#4675) and the recomputation is now mandatory rather'); + L.push('than remembered. **Never hand-patch a number here** — fix the code or the verdict and'); + L.push('regenerate.'); + L.push(''); + + L.push('## Global'); + L.push(''); + L.push('| Measure | Value |'); + L.push('|---|---|'); + L.push(`| Triaged directories | ${g.dirs} |`); + L.push(`| Object sites in them | ${g.sites} |`); + L.push(`| Still-open (strip) sites | ${g.strip} |`); + L.push(`| Files carrying at least one | ${g.openFiles} |`); + L.push(''); + L.push('Remaining strip sites by class:'); + L.push(''); + L.push('| Bucket | Sites |'); + L.push('|---|---|'); + for (const b of BUCKETS) { + if (!g.buckets[b] && b === 'unclassified') continue; + L.push(`| ${BUCKET_LABEL[b]} | ${g.buckets[b]} |`); + } + L.push(''); + + L.push('## Posture, per triaged directory'); + L.push(''); + L.push('The `strict` column is the one the campaign schedules against; it counts both the'); + L.push('`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since'); + L.push('#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed.'); + L.push(''); + L.push('| Dir | Sites | strict | passthrough | catchall | strip |'); + L.push('|---|---|---|---|---|---|'); + for (const t of model.triaged) { + L.push( + `| \`${t.dir}/\` | ${t.sites} | ${t.posture.strict} | ${t.posture.passthrough} | ` + + `${t.posture.catchall} | ${t.posture.strip} |`, + ); + } + L.push( + `| **total** | **${g.sites}** | **${g.posture.strict}** | **${g.posture.passthrough}** | ` + + `**${g.posture.catchall}** | **${g.posture.strip}** |`, + ); + L.push(''); + + L.push('## File-level triage — site counts'); + L.push(''); + L.push('Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` /'); + L.push('`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to'); + L.push('classify and is not listed (it becomes reportable the day it grows its first site).'); + L.push(''); + for (const t of model.triaged) { + // Headers carry no numbers, so their anchors are stable across every batch — + // the ledger links into this file, and a link that breaks whenever a count + // moves is a link that will be wrong exactly when someone follows it. + L.push(`### \`${t.dir}/\` — sites`); + L.push(''); + L.push('| File | Sites |'); + L.push('|---|---|'); + for (const f of t.files) L.push(`| \`${f.file}\` | ${f.sites} |`); + L.push(`| **total** | **${t.sites}** |`); + L.push(''); + } + + L.push('## Remaining strip sites — the batch-planning map'); + L.push(''); + L.push('Per file, how many of its sites still silently discard unknown keys. The `Class`'); + L.push('column that decides the bucket split is hand-written in the ledger; the arithmetic'); + L.push('over it is here.'); + L.push(''); + for (const t of model.triaged) { + L.push(`### \`${t.dir}/\` — open`); + L.push(''); + L.push(`**${t.strip} strip of ${t.sites}**, in ${t.openFiles.length} file(s).`); + L.push(''); + if (!t.openFiles.length) { + L.push('This directory is closed.'); + L.push(''); + continue; + } + L.push('| File | Strip | Sites |'); + L.push('|---|---|---|'); + for (const f of t.openFiles) L.push(`| \`${f.file}\` | ${f.strip} | ${f.sites} |`); + L.push(`| **total** | **${t.strip}** | **${t.sites}** |`); + L.push(''); + L.push('| Bucket | Sites |'); + L.push('|---|---|'); + for (const b of BUCKETS) { + if (!t.buckets[b] && b === 'unclassified') continue; + L.push(`| ${BUCKET_LABEL[b]} | ${t.buckets[b]} |`); + } + L.push(''); + } + + L.push('## Other directories (untriaged)'); + L.push(''); + L.push('Site totals only — these directories are classified coarsely in the ledger, per'); + L.push('directory rather than per file.'); + L.push(''); + L.push('| Dir | Sites |'); + L.push('|---|---|'); + for (const o of model.other) L.push(`| \`${o.dir}/\` | ${o.sites} |`); + L.push(''); + + return `${L.join('\n')}`; +} + +/* ------------------------------------------------------------------ plumbing */ + +export interface LoadedLedger { + /** Absolute path of the hand-written ledger. */ + ledgerPath: string; + /** Absolute path of the generated counts artifact. */ + countsPath: string; + ledgerText: string; + parsed: ParsedLedger; + model: CountsModel; + /** Ledger defects found while computing the model (bad `Class` cells). */ + problems: string[]; + /** The artifact as it SHOULD be on disk right now. */ + rendered: string; +} + +/** + * One loader for both `gen:` and `check:`. + * + * They must not compute the model two ways — the freshness gate works by + * rendering and byte-comparing, so a second implementation would be a second + * answer, and the failure mode of two answers is a gate that goes green on a + * wrong file. This repo has that scar in four separate instruments already. + */ +export function loadLedger(repoRoot: string, specSrc: string): LoadedLedger { + const ledgerPath = path.join(repoRoot, LEDGER_PATH); + const countsPath = path.join(repoRoot, COUNTS_PATH); + const ledgerText = fs.readFileSync(ledgerPath, 'utf-8'); + const parsed = parseLedger(ledgerText); + + const allDirs = fs + .readdirSync(specSrc, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + + const { model, problems } = buildCounts( + specSrc, + parsed, + (dir) => fs.existsSync(path.join(specSrc, dir)), + allDirs, + ); + + return { ledgerPath, countsPath, ledgerText, parsed, model, problems, rendered: renderCounts(model) }; +} diff --git a/packages/spec/scripts/lib/strictness-ledger.ts b/packages/spec/scripts/lib/strictness-ledger.ts index 8dd510cbe2..4bc409da84 100644 --- a/packages/spec/scripts/lib/strictness-ledger.ts +++ b/packages/spec/scripts/lib/strictness-ledger.ts @@ -51,12 +51,44 @@ function idiomOf(call: ts.CallExpression): string | null { return null; } -/** Posture from the method chain applied to the site (`.strict()`, `.passthrough()`, …). */ +/** + * Posture from the method chain applied to the site (`.strict()`, `.passthrough()`, …). + * + * ## The idiom decides the START of the walk, never its end (#5072) + * + * This used to `return 'strict'` on the first line for `strictObject(` / + * `z.strictObject(` — the campaign's own helper — and `return 'passthrough'` for + * `z.looseObject(`, without looking at what was chained onto them. Only + * `z.object(` ever walked the chain. So `strictObject(…).passthrough()` — a shape + * that is **open at runtime**, deliberately — was recorded as `strict`. + * + * Two sites in the repo, both in `ui/view.zod.ts` (`GanttConfigSchema`, + * `TreeConfigSchema`), both open on purpose so renderer-ahead config knobs reach + * `plugin-gantt` / `plugin-tree` without waiting on a spec release. Measured, not + * inferred: `GanttConfigSchema.parse({ …required, lockField: 'x' })` returns + * `lockField` untouched. + * + * Two things made it worth fixing rather than rounding away. It **inflated the + * strict count**, which is the number the campaign schedules against; and, the + * worse half, it drew an OPEN shape on the map as a CLOSED one — the exact + * question this instrument exists to answer, answered backwards. Same shape as + * the #4852 defect (a counter returning a wrong reading that the walk then + * correctly honoured), one field over: there the blind spot was in `count`, here + * in `posture`. #4852 rebuilt `idiomOf`/`nameOf` on the AST and left this one + * short-circuit behind. + * + * So the idiom now seeds the initial posture and the chain is ALWAYS walked, with + * the last explicit call winning. `strictObject({}).passthrough()` reads + * `passthrough`; a bare `strictObject({})` still reads `strict`. + */ function postureOf(call: ts.CallExpression, idiom: string): Posture { - if (idiom === 'strictObject' || idiom === 'z.strictObject') return 'strict'; - if (idiom === 'z.looseObject') return 'passthrough'; let node: ts.Node = call; - let posture: Posture = 'strip'; + let posture: Posture = + idiom === 'strictObject' || idiom === 'z.strictObject' + ? 'strict' + : idiom === 'z.looseObject' + ? 'passthrough' + : 'strip'; for (;;) { const parent: ts.Node | undefined = node.parent; if (!parent) return posture; diff --git a/packages/spec/scripts/strictness-ledger-doc.test.ts b/packages/spec/scripts/strictness-ledger-doc.test.ts new file mode 100644 index 0000000000..d502bcd490 --- /dev/null +++ b/packages/spec/scripts/strictness-ledger-doc.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Regression cover for the ledger's numbers/prose split (#5107). + * + * The split exists because the ledger's NUMBERS merged clean and wrong seven + * times in one day while its PROSE merged fine. Moving the numbers into a + * generated artifact fixes that — and introduces exactly one new way to be + * quietly wrong: the per-class subtotals are arithmetic over a hand-written + * `Class` cell, so a tolerant parser would put the campaign's numbers straight + * back where they were, in a green file. + * + * So the parser's REFUSALS are the load-bearing cases here, not its acceptances. + * Each one is a shape that would otherwise be published as a confident subtotal. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { analyzeTree } from './lib/strictness-ledger'; +import { + BUCKETS, + COUNTS_PATH, + LEDGER_PATH, + VERDICTS, + bucketize, + loadLedger, + parseClassCell, + renderCounts, +} from './lib/strictness-ledger-doc'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const SPEC = path.resolve(HERE, '..'); +const REPO = path.resolve(SPEC, '../..'); +const SRC = path.join(SPEC, 'src'); + +describe('Class cell grammar', () => { + it('reads a bare verdict through the markdown the ledger writes it in', () => { + // Every one of these spellings exists in the file today. + expect(parseClassCell('wire')).toMatchObject({ verdict: 'wire', provisional: false, breakdown: null }); + expect(parseClassCell('**no door**')).toMatchObject({ verdict: 'no door', provisional: false }); + expect(parseClassCell('wire (p)')).toMatchObject({ verdict: 'wire', provisional: true }); + expect(parseClassCell('**split** · 5 no door')).toMatchObject({ + verdict: 'split', + breakdown: [{ n: 5, verdict: 'no door' }], + }); + }); + + it('refuses a verdict outside the vocabulary rather than bucketing it as "other"', () => { + // `no door` and `no gate` imply OPPOSITE follow-ups — retiring a `no gate` + // shape deletes something authors use. A parser that shrugged at an unknown + // word would silently drop a row out of every subtotal. + expect(parseClassCell('probably fine')).toBeNull(); + expect(parseClassCell('')).toBeNull(); + expect(parseClassCell('mixed · six authorable')).toBeNull(); + }); + + it('maps `verify` to authorable, because that is what the ledger has always counted', () => { + // `ui/app.zod.ts`'s single site: held pending the finding-16 `.extend()` + // check, and counted in the published `view` 6 + `app` 1 = 7 all the same. + // A readiness flag, not a class. + const parsed = parseClassCell('verify'); + expect(parsed).not.toBeNull(); + expect(bucketize(parsed!, 1)).toEqual({ buckets: { ...zero(), authorable: 1 } }); + }); +}); + +describe('bucketize refuses to guess', () => { + it('rejects a resolved mixed/split row that states no split', () => { + const parsed = parseClassCell('mixed')!; + const res = bucketize(parsed, 6); + expect('error' in res).toBe(true); + expect('error' in res && res.error).toMatch(/state the split/); + }); + + it('accepts the same row once it carries `(p)`, and calls it unresolved', () => { + // This is the honest answer, and it is what `data/`'s "needs a per-schema + // verdict" bucket is made of. Not the same as zero authorable. + const parsed = parseClassCell('mixed (p)')!; + expect(bucketize(parsed, 12)).toEqual({ buckets: { ...zero(), unresolved: 12 } }); + }); + + it('rejects a declared split that does not add up to the row', () => { + // The failure this prevents is arithmetic that looks deliberate: a batch + // closes two sites, updates the prose, and leaves the split naming a total + // that no longer exists. + const parsed = parseClassCell('mixed · 6 authorable')!; + const res = bucketize(parsed, 8); + expect('error' in res).toBe(true); + expect('error' in res && res.error).toMatch(/sums to 6 but the file has 8/); + }); + + it('rejects a split whose parts are themselves not single classes', () => { + const parsed = parseClassCell('mixed · 3 mixed, 3 authorable')!; + expect('error' in bucketize(parsed, 6)).toBe(true); + }); +}); + +describe('the ledger and the generated counts agree', () => { + const loaded = () => loadLedger(REPO, SRC); + + it('has no unresolved Class cells — every row buckets', () => { + const { problems, model } = loaded(); + expect(problems).toEqual([]); + expect(model.global.buckets.unclassified).toBe(0); + }); + + it('buckets partition the strip sites exactly, per directory and globally', () => { + // Internal consistency rather than golden numbers: the totals move with every + // batch, but a bucket split that does not partition its own total is a defect + // in any tree. This is the property the hand-written subtotals could not have + // — they were sums nobody re-derived. + const { model } = loaded(); + for (const t of model.triaged) { + const sum = BUCKETS.reduce((a, b) => a + t.buckets[b], 0); + expect(sum, `${t.dir}/ buckets must sum to its strip count`).toBe(t.strip); + expect(t.strip).toBe(t.openFiles.reduce((a, f) => a + f.strip, 0)); + } + expect(BUCKETS.reduce((a, b) => a + model.global.buckets[b], 0)).toBe(model.global.strip); + }); + + it('measures the same tree the AST counter does', () => { + const { model } = loaded(); + for (const t of model.triaged) { + const sites = analyzeTree(path.join(SRC, t.dir)); + expect(t.sites, `${t.dir}/ site total`).toBe(sites.length); + expect(t.strip, `${t.dir}/ strip total`).toBe(sites.filter((s) => s.posture === 'strip').length); + } + }); + + it('is checked in current — the artifact on disk equals a fresh render', () => { + // The same comparison `check:strictness-ledger` makes. Duplicated here on + // purpose: a stale artifact should be visible from `pnpm test` too, because + // the failure it stands for ("a schema moved under a verdict nobody + // re-examined") is a code change, and code changes run the suite. + const { rendered } = loaded(); + expect(fs.readFileSync(path.join(REPO, COUNTS_PATH), 'utf-8')).toBe(rendered); + }); + + it('renders deterministically', () => { + const { model } = loaded(); + expect(renderCounts(model)).toBe(renderCounts(model)); + }); + + it('writes headers that carry no numbers, so links into it cannot rot', () => { + // The ledger links into this file by anchor. Number-bearing headings + // (`### \`ui/\` — 76 strip of 198`) change their anchor on every batch, i.e. + // exactly when someone follows the link. + const headings = fs + .readFileSync(path.join(REPO, COUNTS_PATH), 'utf-8') + .split('\n') + .filter((l) => /^#{2,3} /.test(l)); + expect(headings.length).toBeGreaterThan(4); + for (const h of headings) expect(h, `${h} must not carry a count`).not.toMatch(/\d/); + }); +}); + +describe('the ledger documents the grammar the parser enforces', () => { + it('names every verdict the parser accepts, and no others', () => { + // Two copies of a vocabulary drift, and the direction they drift in is the + // one where an author writes what the doc says and the gate rejects it. + const md = fs.readFileSync(path.join(REPO, LEDGER_PATH), 'utf-8'); + const block = md.split('## Classification rule')[0]; + const documented = new Set( + [...block.matchAll(/`(authorable|verify|mixed|split|wire|open|no door|no gate)`/g)].map((m) => m[1]), + ); + expect([...documented].sort()).toEqual([...VERDICTS].sort()); + }); + + it('tells the reader the artifact is generated and how to regenerate it', () => { + const md = fs.readFileSync(path.join(REPO, LEDGER_PATH), 'utf-8'); + expect(md).toContain('gen:strictness-ledger'); + expect(md).toContain(path.basename(COUNTS_PATH)); + }); +}); + +function zero(): Record { + return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 }; +} diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 1627b10543..94ee282de0 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -184,4 +184,82 @@ describe('posture reading, with a red control for each', () => { const dash = analyzeSites(at('ui/dashboard.zod.ts')); expect(dash.filter((s) => s.posture === 'passthrough')).toHaveLength(1); }); + + /** + * #5072. `postureOf` short-circuited on the campaign's OWN helper: it returned + * `strict` for `strictObject(` before looking at the chain, so + * `strictObject(…).passthrough()` — open at runtime, deliberately — was drawn on + * the map as closed. Only `z.object(` ever walked the chain. + * + * The two real sites are the anchors here, because the point is not "the parser + * handles a chain" but "the map stopped lying about these two". Each is paired + * with the plain-helper control in the same file, so the two readings must + * differ — a test that only asserted `passthrough` would still pass if the + * reader started calling everything passthrough. + */ + it('does not stop at the strictObject( idiom — .passthrough() on it wins (#5072)', () => { + const view = analyzeSites(at('ui/view.zod.ts')); + + for (const name of ['GanttConfigSchema', 'TreeConfigSchema']) { + const site = view.find((s) => s.name === name); + expect(site, `${name} is not a site any more — re-point this test, do not delete it`).toBeDefined(); + // The idiom is still the helper; only the READING of the chain changed. + expect(site?.idiom).toBe('strictObject'); + expect(site?.posture, `${name} must read as passthrough — it is open at runtime`).toBe('passthrough'); + } + + // The control, in the same file and the same idiom: a bare `strictObject(` + // with nothing chained on is still strict. Without this the assertion above + // is satisfied by a reader that has simply stopped distinguishing. + const plain = view.find((s) => s.name === 'GanttQuickFilterSchema'); + expect(plain?.idiom).toBe('strictObject'); + expect(plain?.posture).toBe('strict'); + + // Exactly two, and exactly these two. Pinned as a SET rather than a count so + // a future batch adding a deliberate `.passthrough()` has to come through + // here and say so, instead of quietly widening a number. + expect(view.filter((s) => s.posture === 'passthrough').map((s) => s.name)).toEqual([ + 'GanttConfigSchema', + 'TreeConfigSchema', + ]); + + // And the claim the ledger rests on: no STRIP site moved. Both postures + // involved are non-strip, so the remaining-strip map — the thing every batch + // is planned against — is untouched by this fix. + expect(countStripSites(at('ui/view.zod.ts'))).toBe(6); + }); + + it('lets a chained posture override the idiom in either direction (#5072)', () => { + // Synthetic, because the repo has no `.strict()` on a loose object and no + // `z.looseObject(` under a triaged directory — the early return for + // `z.looseObject` was the same defect waiting for its first instance, so it + // is covered before that instance exists rather than after. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'strictness-idiom-')); + const file = path.join(dir, 'synthetic.zod.ts'); + fs.writeFileSync( + file, + [ + 'export const A = strictObject({ a: 1 }).passthrough();', + 'export const B = strictObject({ b: 1 });', + 'export const C = z.looseObject({ c: 1 }).strict();', + 'export const D = z.looseObject({ d: 1 });', + 'export const E = z.strictObject({ e: 1 }).catchall(z.unknown());', + // Last explicit call wins, not the first. + 'export const F = strictObject({ f: 1 }).passthrough().strict();', + ].join('\n'), + ); + try { + const posture = Object.fromEntries(analyzeSites(file).map((s) => [s.name, s.posture])); + expect(posture).toEqual({ + A: 'passthrough', + B: 'strict', + C: 'strict', + D: 'passthrough', + E: 'catchall', + F: 'strict', + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 094a5bd63e..26c3dddbec 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -39,6 +39,17 @@ export const REGEN_ARTIFACTS = Object.freeze([ readsDist: true, }, { path: 'content/docs/references/**', gen: 'gen:docs', check: 'check:docs' }, + // #5107. Unlike its neighbours this one is derived from the AST *plus* a + // hand-written column (the ledger's `Class` verdicts feed the per-class + // subtotals), which is exactly why it belongs here rather than in the ledger: + // the arithmetic composes on a merge, the judgement does not, so they had to + // stop living in one file. The ledger itself stays hand-written and is NOT + // driver-managed — regenerating prose would delete somebody's evidence. + { + path: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md', + gen: 'gen:strictness-ledger', + check: 'check:strictness-ledger', + }, ]); /** @@ -79,7 +90,12 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ }, { path: 'docs/audits/**', - why: 'hand-written audit ledgers. `check:strictness-ledger` audits one against the code; there is no generator.', + why: + 'hand-written audit ledgers — Class verdicts, evidence, findings logs. The ONE exception is ' + + 'the strictness ledger\'s `.counts.md`, declared above: #5107 split the ledger\'s numbers out ' + + 'precisely so the prose could keep text-merging (it always merged cleanly) while the numbers ' + + 'stopped (they merged clean and WRONG). Regenerating a ledger would discard evidence, which is ' + + 'the opposite trade — so this exclusion covers the prose and must stay.', }, ]);