Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/strictness-ledger-covered-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': patch
---

tooling: strictness 台账新增第九个判定词 `covered` —— 给「无门、无 parse,但每个消费者都已把守」的形状片段一个诚实的格子 (#5249)

`docs/audits/2026-07-unknown-key-strictness-ledger.md` 的 `Class` 列是**机读**的(按类小计是对它做算术),枚举值此前只有八个。`ui/app.zod.ts` 的 `BaseNavItemSchema` 八个都不合适,而这不是标签精度问题 —— **是词汇表返回了错误的动作**:

- 两轴表(carrier / parse)把「carrier 缺席 + parse 缺席」解析成 `no door`,其规定的后续动作是 ADR-0049 退役。**在这里是破坏性的**:这个基底的键被九个导航分支共享,九个分支各自 `.strict()` 并带 `navItemUnknownKeyError`,退役等于删掉九个活着的分支的共享键。
- `no gate` 反向错(门是存在的,就在成员上)。`authorable` 是 `view.zod.ts` 里 `FormFieldBaseSchema` 的先例,但**那个基底真的被 `.extend()`**,姿态会继承,所以它是一扇真门;把这个也记成 `authorable`,等于邀请下一轮 sweep 去「把活干完」—— 收紧一个没有任何 parse 的形状。
- `verify` 的语义是「待检查」,而检查在批 19 就做完了。

维护者 2026-08-06 裁定取 A:**加词,而不是四舍五入到最近的错误答案** —— 与批 15 加 `no gate` 同一条理由。同一个测量第二次返回相反方向的后续动作,说明缺的是一格判定,不是这个站点特殊。这一格的读者主要是后续 agent,一个指向错误**动作**的分类,会被执行它的人放大,正是「消费者侧宽容」在分类层的镜像。

**`covered` 的定义**:carrier 缺席、parse 缺席,但词汇在**每个消费者处都被完整把守**;后续动作是**无**。它拿到自己的桶而不是并入 `no door`:两者测量相同,但小计是一张工作清单,而两行规定的工作相反 —— 合并会把刚刚消除的歧义原样搬到上一层。

改动面:

- `packages/spec/scripts/lib/strictness-ledger-doc.ts` —— `VERDICTS` / `BUCKETS` / `BUCKET_OF` / `emptyBuckets()` / 渲染标签。`verify` 的**语义与归桶完全不动**(仍计入 authorable),它继续为下一个需要挂起的站点保留。
- 台账 `ui/app.zod.ts` 行 `verify` → `covered`;头部散文补上词表变更的出处一行(批 13 `no door` / 批 15 `no gate` / #5249 `covered`),分类表与两轴表各补一行。
- `.counts.md` 走 `gen:strictness-ledger` 整体重算:全局 authorable 43 → 42、新增 `covered` 1;`ui/` authorable 34 → 33、`covered` 1。总数仍是 197,分桶仍恰好划分。

**改判范围是测量出来的,不是走过场。** 判据是机械的:`covered` 要求键通过 `...X.shape` **展开**到达消费者 —— 展开把逐键 schema 复制进一个全新的 `z.object`,姿态是新对象自己的,所以基底是惰性的;而 `.extend()` / `.merge()` / `.omit()` **继承**姿态,基底就还是一扇真门。对五个已分诊目录的全部 **197** 个 strip 站点跑了这条判据,**只有一个**站点是展开的,就是本行。另外三个模块私有的 strip 基底各有归宿且**维持原判**:`view.zod.ts` 的 `FormFieldBaseSchema` 在 `:1475` 被 `.extend()`(姿态继承 → 真门 → 仍 `authorable`);`query.zod.ts` 的 `BaseQuerySchema` 在 `:485` 被 `.extend()` 成 `QuerySchema`(同理 → 仍 `open`);`component.zod.ts` 的 `EmptyProps` 作为**值**挂在 `ComponentPropsMap` 的十一个 carrier 键下(carrier 存在 → 根本不满足「carrier 缺席」)。其余约 50 个站点是属性下的内联嵌套字面量,天然自带 carrier,不可能是 `covered`。

不改任何 schema 姿态 —— 批 19 已测定关掉这个基底是保证的 no-op,而 #4583 明确 no-op 收紧并非中性(*"a precisely-validated dead slot is the more convincing lie"*)。
9 changes: 7 additions & 2 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,12 @@ Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 43 |
| authorable — the ruling's forced scope | 42 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 107 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |

## Posture, per triaged directory

Expand DownExpand Up@@ -171,11 +172,12 @@ over it is here.

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 34 |
| authorable — the ruling's forced scope | 33 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |

### `data/` — open

Expand DownExpand Up@@ -208,6 +210,7 @@ over it is here.
| wire / open — out of forced scope | 65 |
| no door — no carrier, ADR-0049 territory | 0 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |

### `automation/` — open

Expand All@@ -229,6 +232,7 @@ over it is here.
| wire / open — out of forced scope | 26 |
| no door — no carrier, ADR-0049 territory | 0 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |

### `security/` — open

Expand All@@ -247,6 +251,7 @@ over it is here.
| wire / open — out of forced scope | 13 |
| no door — no carrier, ADR-0049 territory | 0 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |

### `studio/` — open

Expand Down
53 changes: 36 additions & 17 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md

Large diffs are not rendered by default.

67 changes: 57 additions & 10 deletions packages/spec/scripts/lib/strictness-ledger-doc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,10 +54,29 @@ import { analyzeSites, listSchemaFiles, type Posture } from './strictness-ledger

/**
* 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".
* measurement that needed it, and three of them (`no door` / `no gate` /
* `covered`) imply MUTUALLY EXCLUSIVE follow-ups off the SAME measurement —
* retiring a `no gate` shape deletes something authors use, and retiring a
* `covered` one deletes a key its consumers still gate. So the parser rejects
* anything outside this set rather than bucketing it as "other".
*
* ## Why the list grows instead of rounding to the nearest word (#5249)
*
* `covered` is the third verdict added because the existing vocabulary returned
* the WRONG ACTION rather than merely an imprecise label, and that is the only
* bar a new word has to clear. Twice now the two-axis read (carrier / parse) has
* produced one answer for two situations whose right next steps are opposite:
*
* - 批 15 added `no gate` — parse absent, but the carrier is LIVE, so the fix
* is to wire the parse, not to retire the vocabulary;
* - #5249 adds `covered` — carrier absent AND parse absent (mechanically `no
* door`), but the vocabulary is fully guarded at every consumer, so the
* follow-up is NOTHING. `no door` would have pointed the next agent at
* ADR-0049 retirement of a key nine live nav branches share.
*
* The cell is read by future agents, so a verdict that names the wrong action is
* the classification-layer mirror of a lenient consumer: the error does not stop
* at the cell, it is amplified by whoever acts on it.
*/
export const VERDICTS = [
'authorable',
Expand All@@ -68,20 +87,38 @@ export const VERDICTS = [
'open',
'no door',
'no gate',
'covered',
] 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 const BUCKETS = [
'authorable',
'unresolved',
'wire/open',
'no door',
'no gate',
'covered',
'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.
* `verify` counts as **authorable** — a readiness flag on an authorable site, not
* a third class, and the ledger's published subtotal (`view` 6 + `app` 1 = 7)
* counted it that way from the start. The mapping is unchanged at #5249; what
* changed is that its one instance (`ui/app.zod.ts`'s `BaseNavItemSchema`, held
* pending the finding-16 `.extend()` check) has since been checked and moved to
* `covered`. `verify` keeps both its meaning and its bucket for the next site
* that needs holding.
*
* `covered` gets its **own** bucket rather than sharing `no door`'s. Both are
* carrier-absent + parse-absent, so an arithmetic merge would be defensible — but
* the subtotal is a worklist readout, and the two rows prescribe opposite work
* (`no door` → ADR-0049 retirement; `covered` → none). Folding them would restore
* exactly the ambiguity the verdict was added to remove, one layer up.
*
* `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`
Expand All@@ -94,6 +131,7 @@ const BUCKET_OF: Partial<Record<Verdict, Bucket>> = {
open: 'wire/open',
'no door': 'no door',
'no gate': 'no gate',
covered: 'covered',
};

export interface ParsedClass {
Expand DownExpand Up@@ -220,7 +258,15 @@ export function bucketize(
}

export function emptyBuckets(): Record<Bucket, number> {
return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 };
return {
authorable: 0,
unresolved: 0,
'wire/open': 0,
'no door': 0,
'no gate': 0,
covered: 0,
unclassified: 0,
};
}

/* -------------------------------------------------------------- ledger parse */
Expand DownExpand Up@@ -464,6 +510,7 @@ const BUCKET_LABEL: Record<Bucket, string> = {
'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',
covered: 'covered — no carrier, no parse, guarded at every consumer',
unclassified: '⚠️ unclassified — no ledger row',
};

Expand Down
78 changes: 71 additions & 7 deletions packages/spec/scripts/strictness-ledger-doc.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,15 +58,67 @@ describe('Class cell grammar', () => {
});

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.
// A readiness flag, not a class — counted in the published `view` 6 +
// `app` 1 = 7 from the start. #5249 moved its one instance to `covered` and
// deliberately left this mapping alone: the word still means "held pending a
// check" and the next site that needs holding must count the same way.
const parsed = parseClassCell('verify');
expect(parsed).not.toBeNull();
expect(bucketize(parsed!, 1)).toEqual({ buckets: { ...zero(), authorable: 1 } });
});
});

describe('`covered` — the ninth verdict (#5249)', () => {
it('parses, and lands in a bucket of its own', () => {
const parsed = parseClassCell('covered');
expect(parsed).toMatchObject({ verdict: 'covered', provisional: false, breakdown: null });
expect(bucketize(parsed!, 1)).toEqual({ buckets: { ...zero(), covered: 1 } });
});

it('does NOT merge into `no door`, which is the whole reason it exists', () => {
// Both are carrier-absent + parse-absent, so an arithmetic merge would look
// harmless. It is not: the subtotal is a worklist readout and the two rows
// prescribe OPPOSITE work — `no door` sends the next agent to ADR-0049
// retirement, `covered` sends them nowhere because every consumer already
// gates the keys. Retiring `ui/app.zod.ts`'s `BaseNavItemSchema` on a
// `no door` reading would delete nine live nav branches' shared keys.
const covered = bucketize(parseClassCell('covered')!, 3);
const noDoor = bucketize(parseClassCell('no door')!, 3);
expect(covered).not.toEqual(noDoor);
expect('buckets' in covered && covered.buckets['no door']).toBe(0);
expect('buckets' in noDoor && noDoor.buckets.covered).toBe(0);
});

it('is a single class, so a split may be declared in terms of it', () => {
// The ledger's `ui/i18n.zod.ts` row is `split · 5 no door`; a file that ever
// mixes a covered fragment with live sites must be able to say so, and
// `bucketize` rejects breakdown parts that name no single class.
const parsed = parseClassCell('split · 2 covered, 1 authorable')!;
expect(bucketize(parsed, 3)).toEqual({ buckets: { ...zero(), covered: 2, authorable: 1 } });
});

it('is reported under a label that names the measurement, not just the word', () => {
// The counts artifact is read by people who never open this file, so the
// bucket label has to carry the three-part test (`no carrier, no parse,
// guarded at every consumer`) rather than a bare `covered`.
const { rendered } = loadLedger(REPO, SRC);
expect(rendered).toContain('covered — no carrier, no parse, guarded at every consumer');
});

it('has exactly one instance in the tree, and it is `ui/app.zod.ts`', () => {
// The re-review #5249's ruling required, pinned rather than narrated: the
// verdict was created for one measured site, and a second row appearing
// without a measurement is the drift this asserts against. `covered`
// requires the keys to reach consumers by `...X.shape` SPREAD — `.extend()`
// INHERITS posture, which makes the base a real door (`FormFieldBaseSchema`,
// `BaseQuerySchema`) rather than an inert fragment.
const { parsed, model } = loadLedger(REPO, SRC);
const rows = parsed.strip.filter((r) => parseClassCell(r.classCell)?.verdict === 'covered');
expect(rows.map((r) => `${r.dir}/${r.file}`)).toEqual(['ui/app.zod.ts']);
expect(model.global.buckets.covered).toBe(1);
});
});

describe('bucketize refuses to guess', () => {
it('rejects a resolved mixed/split row that states no split', () => {
const parsed = parseClassCell('mixed')!;
Expand DownExpand Up@@ -161,11 +213,15 @@ 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.
//
// The alternation is built FROM `VERDICTS` rather than transcribed. It used
// to be a third hand-written copy of the list, which meant adding a verdict
// (#5249's `covered`) failed here for the one reason this test is not about:
// a word missing from the regex is reported as a word missing from the doc.
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]),
);
const alternation = VERDICTS.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
const documented = new Set([...block.matchAll(new RegExp(`\`(${alternation})\``, 'g'))].map((m) => m[1]));
expect([...documented].sort()).toEqual([...VERDICTS].sort());
});

Expand All@@ -177,5 +233,13 @@ describe('the ledger documents the grammar the parser enforces', () => {
});

function zero(): Record<string, number> {
return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 };
return {
authorable: 0,
unresolved: 0,
'wire/open': 0,
'no door': 0,
'no gate': 0,
covered: 0,
unclassified: 0,
};
}
11 changes: 9 additions & 2 deletions packages/spec/src/ui/app-strictness-batch19.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,12 @@
* #4001 批 19 — `ui/app.zod.ts`'s last strip site, measured.
*
* The file has ONE site left: `BaseNavItemSchema`. The ledger carried it as
* `verify` — held pending a check, not scheduled work — with this instruction:
* `verify` — held pending a check, not scheduled work — and, once this check
* came back, held it there a second time while the VOCABULARY question it
* raised went to the maintainer (#5249). That ruling landed on 2026-08-06 and
* added a ninth verdict, `covered`, which the row now carries; the measurement
* below is what `covered` means, and is unchanged by the renaming. The original
* instruction was:
*
* > `BaseNavItemSchema` — the base the strict discriminated-union members
* > extend. Closing a base that is `.extend()`ed is the #4001 trap that bit
Expand DownExpand Up@@ -37,7 +42,9 @@
* than an omission — the third place it is recorded, beside the schema comment
* and the `ui/` ledger row. If a future member is ever written as
* `BaseNavItemSchema.extend({...})` WITHOUT its own `.strict()`, the last test
* here is the one that should start failing.
* here is the one that should start failing — and that is also the one change
* that would take the row back out of `covered`, since `covered` rests on the
* spread (posture not inherited) plus every consumer's own `.strict()`.
*/

import { describe, it, expect } from 'vitest';
Expand Down
9 changes: 7 additions & 2 deletions packages/spec/src/ui/app.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,8 +201,13 @@ const actionDefUnknownKeyError = strictUnknownKeyError({
* nothing while making a shape fragment look load-bearing (#4583: *"a
* precisely-validated dead slot is the more convincing lie"*).
*
* The `Class` cell it should carry is an OPEN question (#5249): the ledger's
* enumerated vocabulary has no word for a shape that is neither a door nor dead.
* The `Class` cell it should carry was an open question (#5249), because the
* ledger's enumerated vocabulary had no word for a shape that is neither a door
* nor dead. **Ruled 2026-08-06: the vocabulary grew one — `covered`** (carrier
* absent, parse absent, vocabulary fully gated at every consumer; follow-up:
* none), and this schema is its only instance in the five triaged directories —
* a spread is what makes the base inert, and it is the only strip site in all
* 197 that is spread rather than `.extend()`ed or carried under a key.
*
* Pinned in `app-strictness-batch19.test.ts`, including the mechanism itself
* (`.extend()` inherits posture, `...shape` does not) and a guard that fails if
Expand Down
Loading