Skip to content

Commit abb01f1

Browse files
os-steveclaude
andauthored
fix(spec): the agreement shape is an offence — name the unit in two describes and remove the carve-out (#19035)
Fixes #18075 Executes **batch #158 item 5 · letter A** (director seat summon #24, maintainer 「同意」 2026-09-18T11:14Z). The ruling's verbatim scope: > the agreement shape — a unit in the key name, the same unit in the JSDoc, no unit in `.describe()` — **IS an offence**: the carve-out `!site.jsdocUnits.some((u) => site.keyUnits.includes(u))` is **removed**, the two live rows get a `.describe()` that **names the unit**, the two DEFERRED self-tests **turn positive**, and the header's **shape (b) base refusal is restored** Clause-②: no ## The four deliverables | # | Deliverable | Where | |:--|:--|:--| | 1 | The two live rows name their unit in the published channel | `packages/spec/src/ai/usage.zod.ts` `latencyMs` (had **no** `.describe()` at all) and `packages/spec/src/system/tenant.zod.ts` `frequencyHours` (`'Backup frequency'`, unit-silent) | | 2 | The carve-out is removed | the divergence guard is now `site.keyUnits.length > 0 && site.jsdocUnits.length > 0` | | 3 | The two DEFERRED self-tests turn positive | both now assert `unit-in-jsdoc-not-in-describe`, relabelled `REFUSED (agreement): …` | | 4 | The header's shape (b) base refusal is restored | the "TWO shapes this branch used to refuse" block now names ONE cost — (a), the retired name list — and records (b) as restored | **Landing order is the ruling's:** commit 1 adds the two describes, commit 2 removes the carve-out. `main` is never red in between, and neither is any intermediate commit on this branch — the gate was re-run green after commit 1 alone. Every `DEFERRED to #18075` marker is gone from the checker: five occurrences, **two cases** and three prose passages. The count difference was the card's own warning and it held. ## The class-(a) sibling rides this PR, as ruled > The class-(a) sibling the dev found (the `instant` exemption branch reads `proseUnits` only, never `jsdocUnits`, while `durationType` reads all three) rides the same PR — zero live rows today, a fixture proves it, no separate card. The `EpochMs` instant exemption now reads the JSDoc channel too, under the **same predicate shape** the describe half already used (`length > 0 && !includes('ms')`) — a channel added, not a predicate widened. Two fixtures pin it: an `EpochMs` key whose JSDoc says seconds is refused, and one whose JSDoc says ms is not. **Live rows: 0.** The population run is unchanged at zero offenders with the channel added. ## One extra edit, and why it is not scope creep The finding message told every offender *"the only unit the reader can see is the one the JSDoc disagrees with"* — written when this branch only ever fired on a **contradiction**. The moment agreement became a refusal that sentence was false for half the class, in a file whose own header says *"A gate that cannot see a channel writes falsehoods about it."* The message now names the silent published channel as the harm and keeps the contradiction reading as the conditional half it always was. ## The card's recorded knock-on is discharged, not reworded The card recorded that #15939's changeset over-claims — it says the gate refuses a JSDoc unit the describe does not name *"(or there is no describe at all)"*, which was untrue of exactly these two rows. **That sentence is now true of the gate.** Nothing was edited in place to make it true; the two rows are remediated and the carve-out is gone. The new changeset says so explicitly. ## Changeset: `patch`, measured — not `skip-changeset` `.describe()` text was measured into the published tarball, not assumed. Both new strings appear under paths in `packages/spec`'s `files[]`, with a pre-existing describe (`'Computed cost in USD'`) as the positive control landing the same way: ``` Wall-clock latency in milliseconds -> dist/ai/index.{js,mjs}, json-schema/ai/AIUsageRecord.json, json-schema/objectstack.json Backup frequency in hours -> dist/{browser/,}system/index.{js,mjs}, json-schema/system/{DatabaseLevelIsolationStrategy,TenantIsolationConfig}.json Computed cost in USD (control) -> dist/ai/index.{js,mjs}, json-schema/ai/AIUsageRecord.json ``` A shipped JSON Schema `description` moves, so a released package publishes a change. `patch`, `Clause-②: no`. The reader-facing half regenerated with it — `content/docs/references/system/tenant.mdx` stops printing `frequencyHours | integer | Backup frequency`. ## Verification All readings on the merged head `ce75c01bb9`, exit codes captured before any pipe. **The gate, final tree** — exit 0: `203 unit-declaring numeric key(s) across 2522 source file(s) … zero offenders, no baseline` · self-test `106 case(s) across 13 batteries, every battery at or above its pinned floor`. **The floor did not move.** 104 cases before, 106 after: the two DEFERRED cases turned positive without changing the count, and the two new instant fixtures grew a battery **above** its floor, which the roster documents as ordinary work. `SELF_TEST_BATTERY_FLOOR` and every entry in `SELF_TEST_BATTERIES` are untouched. **Three ablation legs**, each mutated and restored through `scripts/ablation-replace.mjs` (anchor must hit; restore proven by blob hash against HEAD, never by exit code), each run from a committed state: | leg | mutation | result | restore | |:--|:--|:--|:--| | A | re-add the carve-out to the guard | self-test exit 1, **exactly** the 2 agreement cases red, 106 still registered | blob back to `d00c46dfe66b` = HEAD, `git diff HEAD` empty | | B1 | revert `latencyMs`'s describe | gate exit 1, **exactly 1** offender: `[unit-in-jsdoc-not-in-describe] …/usage.zod.ts:52 latencyMs` | blob back to `d241245cd5ae` = HEAD | | B2 | revert `frequencyHours`'s describe | gate exit 1, **exactly 1** offender: `[unit-in-jsdoc-not-in-describe] …/tenant.zod.ts:603 frequencyHours` | blob back to `77b5db008946` = HEAD | | C | delete the instant branch's JSDoc channel | self-test exit 1, exactly the 1 new positive control red | blob back to `ec726de17d8f` = HEAD | Leg B is the one that matters for the ruling: it shows the widened guard catching the ruled shape **on live source**, not only on fixtures. **Package obligations** — `pnpm --filter @objectstack/spec typecheck` exit 0; `pnpm --filter @objectstack/spec test` exit 0, **489 files / 14229 tests passed**; `check:generated` all **16** artefacts up to date after the merge. **Gate families** — `scripts/pm/dispatch-gates.mjs` derived **103** for these paths from its own change set (never a hand-fed list). **102 ran, all exit 0**, reconciled back through `--ran` with each exit code recorded: `103 derived, 102 run, 0 NOT-MEASURED, 1 UNRUN`. - **NOT MEASURED: `pnpm check:pm-dispatch-gates`** — it exceeds this environment's ~10-minute foreground cap (killed at 560s with 1840 lines of passing self-test cases and no verdict). It is a whole-tree-declared family that grades `scripts/pm/dispatch-gates.mjs`, which this diff does not touch. CI runs it. ⛔ Not reported as green. - Four families first returned `PREREQUISITE NOT MET` (unbuilt workspace packages) and one returned exit 3 on a shallow-clone fixture. Each was cleared by building the named package / fetching the pinned commit and **re-run to a real verdict** — ⛔ none is reported from the instrument that did not run. **Repo-wide lint** — `pnpm lint` (`eslint . --no-inline-config`) over the **whole** population at `ce75c01bb9`: exit 0, no narrowing claimed and none needed. **Merge** — `origin/main` moved 10 commits into `packages/spec` while this was in flight, including a breaking spec change. Merged through `scripts/pm/os-regen-merge.sh`, reinstalled, rebuilt, and every reading above re-taken on the merged head. Both describes, both reference pages and the removed carve-out were verified present after the merge. ## Acceptance notes Noted, not filed — recorded so the next reader does not re-open them: - The instant branch and the `durationType` branch still differ in **predicate shape**, not in channel coverage: instant refuses a channel only when it names a unit and **none** of them is `ms`, while `durationType` refuses **each** non-matching unit. A describe naming both `ms` and another unit is therefore tolerated on an instant and refused on a duration type. Zero live rows either way, and the tolerant reading is arguably right for a sentence with an incidental second unit. Deliberately left alone: the ruling authorized a channel, not a predicate. Handler: whichever PR next touches this file. - `packages/spec/src/system/tenant.zod.ts:600-608` carries trailing whitespace on its blank separator lines. No gate reads it; not touched, because this PR's edit there is one line and a whitespace sweep would bury it. Handler: none needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b22db51 commit abb01f1

6 files changed

Lines changed: 140 additions & 64 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`latencyMs` and `frequencyHours` name their unit in the published describe, and `check:duration-unit-keys` refuses the agreement shape
6+
7+
`AIUsageRecord.latencyMs` carried no `.describe()` at all, and
8+
`DatabaseLevelIsolationStrategy.backup.frequencyHours` described `'Backup
9+
frequency'`. Both keys already carried their unit in the key NAME and in a JSDoc
10+
block above it — and neither of those is a channel the published JSON Schema or
11+
`content/docs/references/**` prints. So the reference page published
12+
`frequencyHours | integer | Backup frequency` and left the reader to infer the
13+
unit from the key name, which on a duration is a guess with a 3600x error on the
14+
other side of it. Both describes now name the unit, and the `description` in the
15+
shipped JSON Schema moves with them.
16+
17+
**Ruled 2026-09-18 (decision batch #158 item 5, letter A).** The AGREEMENT shape
18+
— a unit in the key name, the SAME unit in the JSDoc, none in the describe — IS
19+
an offence. `check:duration-unit-keys` carried a carve-out
20+
(`!jsdocUnits.some((u) => keyUnits.includes(u))`) that spared it for one release
21+
while the question sat open, together with two self-test cases pinned as
22+
DEFERRED and a header note recording shape (b) as repealed. The carve-out is
23+
gone, those two cases are POSITIVE controls, and shape (b) is a base refusal
24+
again. Agreement between a key name and a source comment is agreement between
25+
two channels the published page does not print; it says nothing about the one
26+
it does.
27+
28+
⚠️ **This also makes an already-published sentence true.** The changeset for
29+
#15939 states that the gate refuses a key whose JSDoc names a unit its describe
30+
does not, *"or there is no describe at all"* — which over-claimed by exactly the
31+
two rows above while the carve-out stood. The two rows are remediated and the
32+
carve-out is removed, so the claim now holds of the gate; nothing is edited in
33+
place to make it hold.
34+
35+
The `EpochMs` instant exemption reads the JSDoc channel too, riding the same
36+
ruling. It refused a describe that contradicted the schema but never a JSDoc
37+
that did, while the duration-type exemption beside it refused all three
38+
channels — the same lie with two answers depending on which exemption class the
39+
key fell into. No row in the tree carried the shape; a fixture pair pins it.
40+
41+
⛔ No published key, accept set, default or runtime behaviour moves. The two
42+
changes to shipped artefacts are `description` strings.
43+
44+
Clause-②: no

‎content/docs/references/ai/usage.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = AIUsageRecordSchema.parse(data);
4848
| **model** | `string` | ✅ | |
4949
| **usage** | `{ promptTokens: integer; completionTokens: integer; totalTokens: integer }` | ✅ | |
5050
| **costUsd** | `number` | ✅ | Computed cost in USD |
51-
| **latencyMs** | `number` | optional | |
51+
| **latencyMs** | `number` | optional | Wall-clock latency in milliseconds |
5252
| **timestamp** | `string` | optional | |
5353

5454
### Nested Shape: `AIUsageRecord.usage`

‎content/docs/references/system/tenant.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const result = DatabaseLevelIsolationStrategySchema.parse(data);
6868
| Property | Type | Required | Description |
6969
| :--- | :--- | :--- | :--- |
7070
| **strategy** | `Enum<'individual' \| 'consolidated' \| 'on_demand'>` | optional (default: `"individual"`) | Backup strategy |
71-
| **frequencyHours** | `integer` | optional (default: `24`) | Backup frequency |
71+
| **frequencyHours** | `integer` | optional (default: `24`) | Backup frequency in hours |
7272
| **retentionDays** | `integer` | optional (default: `30`) | Backup retention days |
7373

7474
### Nested Shape: `DatabaseLevelIsolationStrategy.encryption`
@@ -344,7 +344,7 @@ This schema accepts one of the following structures:
344344
| Property | Type | Required | Description |
345345
| :--- | :--- | :--- | :--- |
346346
| **strategy** | `Enum<'individual' \| 'consolidated' \| 'on_demand'>` | optional (default: `"individual"`) | Backup strategy |
347-
| **frequencyHours** | `integer` | optional (default: `24`) | Backup frequency |
347+
| **frequencyHours** | `integer` | optional (default: `24`) | Backup frequency in hours |
348348
| **retentionDays** | `integer` | optional (default: `30`) | Backup retention days |
349349

350350
### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].encryption`

‎packages/spec/scripts/check-duration-unit-keys.ts‎

Lines changed: 91 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@
117117
* spelled out here rather than named because the retirement pin below asserts
118118
* that identifier is gone from this file — and that guard's reach was WIDER
119119
* than the bare list words: it admitted any key whose STEM was in the list,
120-
* unit suffix or not. So TWO shapes this branch used to refuse are
121-
* no longer refused here, and both are named rather than one:
120+
* unit suffix or not. So ONE shape this branch used to refuse is no longer
121+
* refused here, and it is named rather than left to be inferred:
122122
*
123123
* (a) a bare list-shaped key that declares nothing (`timeout`, `window`,
124124
* `interval`) whose unit lives only in a JSDoc. It is no longer admitted,
@@ -127,22 +127,23 @@
127127
* `Duration*` type, at which point the schema declares the unit and the
128128
* contradiction branch reaches the key again.
129129
*
130-
* (b) a key whose stem was in the retired list AND whose name carries a unit
131-
* token, whose JSDoc names the SAME unit and whose describe names none
132-
* (`timeoutMs` + JSDoc "in milliseconds" + describe 'Maximum execution
133-
* time'; `intervalSeconds` + JSDoc "in seconds" + no describe). This one
134-
* is still ADMITTED — its NAME declares a unit — and it goes unrefused
135-
* because of the agreement carve-out on the branch below, ⛔ not because
136-
* the retirement removed it from the population. It is DEFERRED to
137-
* #18075, ⛔ not decided correct here: refusing the agreement shape today
138-
* reds `latencyMs` / `frequencyHours` on `main` (that card's ordering
139-
* constraint — remediation before widening), and whether agreement is an
140-
* offence at all is that card's open question.
130+
* ⚠ A SECOND shape — (b), the AGREEMENT shape — was repealed alongside it for
131+
* one release and is RESTORED here as a base refusal: a key whose name carries
132+
* a unit token, whose JSDoc names the SAME unit and whose describe names none
133+
* (`timeoutMs` + JSDoc "in milliseconds" + describe 'Maximum execution time';
134+
* `intervalSeconds` + JSDoc "in seconds" + no describe). It was never removed
135+
* from the population — its NAME declares a unit — it was spared by an
136+
* agreement carve-out on the branch below while the question sat open. Ruled an
137+
* OFFENCE (2026-09-18, decision batch #158 item 5, letter A): the two channels
138+
* that agree are the key name and a source comment, and the one they agree
139+
* about is not the one `content/docs/references/**` prints, so agreement
140+
* between them discharges nothing. The two self-test cases that pinned the
141+
* carve-out are POSITIVE controls now.
141142
*
142-
* What survives as a live refusal is the half resting on a declaration the
143-
* JSDoc CONTRADICTS — a key whose NAME carries a unit, whose describe names
144-
* none, and whose JSDoc names a DIFFERENT unit. Those two channels disagree and
145-
* the disagreement is still refused.
143+
* What survives as a live refusal beside it is the half resting on a
144+
* declaration the JSDoc CONTRADICTS — a key whose NAME carries a unit, whose
145+
* describe names none, and whose JSDoc names a DIFFERENT unit. Those two
146+
* channels disagree and the disagreement is still refused.
146147
*
147148
* Why the divergence is worth a refusal and the blindness was not: the card
148149
* that filed it measured the cost. #15678 recorded in its changeset that
@@ -193,8 +194,8 @@
193194
*
194195
* ⛔ No exemption is a pass on lying. A marked key still fails
195196
* `name-unit-contradicts-prose` (a marker waives the RENAME, never a
196-
* contradiction), an `EpochMs` key whose describe names a unit other than
197-
* milliseconds fails `instant-unit-contradicts-schema` — the schema says
197+
* contradiction), an `EpochMs` key whose describe OR JSDoc names a unit other
198+
* than milliseconds fails `instant-unit-contradicts-schema` — the schema says
198199
* milliseconds, so prose that says seconds is one of the two being wrong — and
199200
* a `DurationMs` key whose prose or name says seconds fails
200201
* `duration-unit-contradicts-schema` for the same reason. A declaration that
@@ -729,17 +730,34 @@ export function judge(site: DurationKey): Finding | undefined {
729730

730731
// Exemption (i): the value IS the shared `EpochMs` schema, so the key is an
731732
// INSTANT and the duration rule does not reach it. The one thing still
732-
// refused is a describe that contradicts the schema: `EpochMs` declares
733-
// milliseconds, so prose naming another unit means the site and the schema
734-
// disagree, and a silent exemption there would let the declaration launder a
735-
// real unit bug.
733+
// refused is a prose channel that contradicts the schema: `EpochMs` declares
734+
// milliseconds, so a describe or a JSDoc naming another unit means the site
735+
// and the schema disagree, and a silent exemption there would let the
736+
// declaration launder a real unit bug.
737+
//
738+
// ⛔ BOTH prose channels are read here, for the same reason and in the same
739+
// direction as the `durationType` branch below reads all of its own — ONLY to
740+
// refuse, never to declare. Reading the describe alone made the two declared
741+
// exemption classes asymmetric on one defect shape: an `EpochMs` key whose
742+
// JSDoc said seconds went unrefused while a `DurationMs` key whose JSDoc said
743+
// seconds was refused, same lie, two answers. Ruled to ride the agreement
744+
// landing (2026-09-18, decision batch #158 item 5, letter A); no live row
745+
// carried the shape, and the fixtures in the exemption battery are what keep
746+
// the two branches from drifting apart again.
736747
if (site.instant) {
748+
const disagreeing: string[] = [];
737749
if (site.proseUnits.length > 0 && !site.proseUnits.includes('ms')) {
750+
disagreeing.push(`the describe says ${site.proseUnits.join('/')}`);
751+
}
752+
if (site.jsdocUnits.length > 0 && !site.jsdocUnits.includes('ms')) {
753+
disagreeing.push(`the JSDoc says ${site.jsdocUnits.join('/')}`);
754+
}
755+
if (disagreeing.length > 0) {
738756
return {
739757
site,
740758
rule: 'instant-unit-contradicts-schema',
741-
message: `${where} — typed \`${INSTANT_ROOT}\` (epoch MILLISECONDS) but the describe says `
742-
+ `${site.proseUnits.join('/')}. One of them is lying; either the describe is wrong or this is `
759+
message: `${where} — typed \`${INSTANT_ROOT}\` (epoch MILLISECONDS) but ${disagreeing.join(' and ')}. `
760+
+ `One of them is lying; either the prose is wrong or this is `
743761
+ `not an epoch-millisecond instant and must not be typed \`${INSTANT_ROOT}\`.`,
744762
};
745763
}
@@ -824,7 +842,7 @@ export function judge(site: DurationKey): Finding | undefined {
824842
// unit. So what is left here is a key admitted by its NAME alone, whose JSDoc
825843
// names a unit that name does not carry.
826844
//
827-
// ⛔ Two halves of the guard, and each one is load-bearing:
845+
// ⛔ ONE half of the guard, and it is load-bearing:
828846
//
829847
// `keyUnits.length > 0` — the key must DECLARE something. The old guard was
830848
// the retired name-shape flag, i.e. the key merely LOOKED like a duration, and that is
@@ -833,30 +851,34 @@ export function judge(site: DurationKey): Finding | undefined {
833851
// a real narrowing of this class and it is recorded as such — see the
834852
// header — not smuggled in as a guard that happens to be equivalent.
835853
//
836-
// the unit must not already BE in the name — a JSDoc that says milliseconds
837-
// over a key called `latencyMs` names no unit the key name does not already
838-
// carry, and the published reference page prints that key name. ⛔ This
839-
// half is DEFERRED to #18075, ⛔ not argued correct here: refusing the
840-
// agreement shape today reds the two live rows on `main` (`latencyMs`,
841-
// `frequencyHours`); whether it is an offence is that card's question. It
842-
// also repeals a base refusal — shape (b) in this file's header — and the
843-
// two self-test cases labelled `DEFERRED to #18075` pin it in both
844-
// directions, so deleting this half of the guard goes red.
854+
// ⛔ THERE IS NO AGREEMENT CARVE-OUT HERE, and the absence is the decision,
855+
// not an omission. A second half spelled `!jsdocUnits.some((u) =>
856+
// keyUnits.includes(u))` sat on this guard for one release and spared the
857+
// shape where the JSDoc names the SAME unit the key name already carries
858+
// (`latencyMs` + "in milliseconds" + no describe at all). Ruled an OFFENCE
859+
// 2026-09-18 (decision batch #158 item 5, letter A): the two channels that
860+
// agree there are the key name and a source comment, and the reader this gate
861+
// exists for reads neither — `content/docs/references/**` prints the describe,
862+
// so agreement upstream of it discharges nothing, and a reader left to infer a
863+
// duration's unit from `Backup frequency` guesses at 3600x stakes. ⛔ Re-add
864+
// the half and the two agreement cases in the self-test go red; they are
865+
// positive controls, not accommodations.
845866
//
846867
// The JSDoc is still NEVER read as a way to SATISFY the rule — that was
847868
// option 1 and it was not adopted. It is read in one direction only: to
848869
// refuse.
849-
if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0
850-
&& !site.jsdocUnits.some((u) => site.keyUnits.includes(u))) {
870+
if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0) {
851871
return {
852872
site,
853873
rule: 'unit-in-jsdoc-not-in-describe',
854874
message: `${where} — the JSDoc above the key names ${site.jsdocUnits.join('/')}, the key name says `
855875
+ `${site.keyUnits.join('/')} and the describe names no unit`
856876
+ `${site.describe === undefined ? ' (there is no describe at all)' : ` (${JSON.stringify(site.describe)})`}. `
857-
+ 'The JSDoc is developer commentary; the describe and the key name are what '
858-
+ '`content/docs/references/**` publishes, so the only unit the reader can see is the one the JSDoc '
859-
+ 'disagrees with. One of them is wrong — fix whichever it is, and state the unit in the describe.',
877+
+ 'The JSDoc is developer commentary and stops at the source file; the describe is what '
878+
+ '`content/docs/references/**` publishes, and it is silent — so the unit is written down twice where '
879+
+ 'the reader of the published page never looks and nowhere they do. State the unit in the describe. '
880+
+ 'Where the JSDoc and the key name name DIFFERENT units, one of those two is also wrong; where they '
881+
+ 'agree, the agreement is between two channels that page does not print and it settles nothing.',
860882
};
861883
}
862884
return undefined;
@@ -1161,6 +1183,16 @@ function selfTest(): number {
11611183
expect('REFUSED (i): an `EpochMs` key whose describe names a unit other than ms → instant-unit-contradicts-schema',
11621184
rulesOf(`const S = z.object({ startedAt: EpochMs.describe('Boot timestamp in seconds') });`)
11631185
.join() === 'instant-unit-contradicts-schema');
1186+
// The JSDoc channel of the SAME exemption, pinned as a PAIR because reading
1187+
// one prose channel and not the other is exactly how the two declared
1188+
// exemptions came apart: `durationType` refused a JSDoc contradiction and
1189+
// `instant` did not, one lie with two answers.
1190+
expect('REFUSED (i): an `EpochMs` key whose JSDoc names a unit other than ms → instant-unit-contradicts-schema',
1191+
rulesOf(`const S = z.object({\n /**\n * Boot timestamp in seconds\n */\n startedAt: EpochMs });`)
1192+
.join() === 'instant-unit-contradicts-schema');
1193+
expect('exempt (i): an `EpochMs` key whose JSDoc names ms AGREES with the schema — the JSDoc refuses, it never declares',
1194+
rulesOf(`const S = z.object({\n /**\n * Boot timestamp in milliseconds\n */\n startedAt: EpochMs });`)
1195+
.join() === '');
11641196
expect('the instant exemption is `EpochMs` ALONE — another identifier root stays outside the population',
11651197
(() => {
11661198
const sites = collectDurationKeys('fixture.ts', `const S = z.object({ startedAt: SomeOtherSchema.describe('Boot timestamp in seconds') });`);
@@ -1318,28 +1350,28 @@ function selfTest(): number {
13181350
rulesOf(`const S = z.object({\n /**\n * Export interval in milliseconds\n */\n intervalSeconds: z.number().int().positive().optional().default(60) });`)
13191351
.join() === 'unit-in-jsdoc-not-in-describe');
13201352

1321-
// ⚠️ THE AGREEMENT CARVE-OUT — DEFERRED to #18075, pinned here so it cannot
1322-
// move silently. These are the two fixtures directly above with ONE word
1323-
// changed: the JSDoc names the SAME unit the key name already carries. The
1324-
// base gate refused both as `unit-in-jsdoc-not-in-describe` — its guard was
1325-
// the retired name-shape predicate, whose reach was the STEM, so `timeoutMs`
1326-
// and `intervalSeconds` both satisfied it. They pass here, and they pass because
1327-
// of the `!jsdocUnits.some(...)` half of the guard below, ⛔ NOT because the
1328-
// retirement removed them from the population and ⛔ NOT because agreement
1329-
// has been ruled not to be an offence.
1353+
// ⚠️ THE AGREEMENT SHAPE — RULED AN OFFENCE (2026-09-18, decision batch
1354+
// #158 item 5, letter A), and these two are its POSITIVE controls. They are
1355+
// the two fixtures directly above with ONE word changed: the JSDoc names the
1356+
// SAME unit the key name already carries. The base gate refused both as
1357+
// `unit-in-jsdoc-not-in-describe`; a carve-out half spelled
1358+
// `!jsdocUnits.some(...)` then spared them for one release while whether
1359+
// agreement is an offence sat open as an undecided question. It is decided:
1360+
// the key name and a JSDoc agreeing with each other are two channels the
1361+
// published reference page does not print, and the describe — the one it does
1362+
// print — is still silent, so nothing about that agreement reaches the reader
1363+
// this rule exists for.
13301364
//
1331-
// #18075 holds the opposite and asked for exactly the first fixture as a
1332-
// POSITIVE control. Refusing it today reds `latencyMs` / `frequencyHours` on
1333-
// `main` — that card's ordering constraint, remediation before widening — so
1334-
// this is a sequencing accommodation with a card attached, not a decision.
1335-
// ⛔ Delete the carve-out and BOTH of these go red: that is what they are
1336-
// for, and it is what was missing when this repeal first landed unnoticed.
1337-
expect('DEFERRED to #18075: `timeoutMs` + JSDoc naming the SAME unit (ms) + describe naming none — base REFUSED this, head does not',
1365+
// ⛔ These two are what fails if the carve-out is ever re-added, by that
1366+
// spelling or another: both fixtures are refused here, and any guard that lets
1367+
// agreement satisfy the JSDoc turns them green again. That is what they are
1368+
// for, and it is what was missing when the repeal first landed unnoticed.
1369+
expect('REFUSED (agreement): `timeoutMs` + JSDoc naming the SAME unit (ms) + describe naming none',
13381370
rulesOf(`const S = z.object({\n /**\n * Execution timeout in milliseconds\n */\n timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time') });`)
1339-
.join() === '');
1340-
expect('DEFERRED to #18075: `intervalSeconds` + JSDoc naming the SAME unit (seconds) + NO describe — base REFUSED this, head does not',
1371+
.join() === 'unit-in-jsdoc-not-in-describe');
1372+
expect('REFUSED (agreement): `intervalSeconds` + JSDoc naming the SAME unit (seconds) + NO describe',
13411373
rulesOf(`const S = z.object({\n /**\n * Export interval in seconds\n */\n intervalSeconds: z.number().int().positive().optional().default(60) });`)
1342-
.join() === '');
1374+
.join() === 'unit-in-jsdoc-not-in-describe');
13431375

13441376
// ⚠️ THE COST OF THE RETIREMENT, pinned rather than quietly dropped. These
13451377
// three shapes were this class's original positive controls (#15939) and

‎packages/spec/src/ai/usage.zod.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const AIUsageRecordSchema = lazySchema(() => z.object({
4949
/** Computed USD cost (promptTokens × promptCostPer1K/1000 + …). */
5050
costUsd: z.number().nonnegative().describe('Computed cost in USD'),
5151
/** Wall-clock latency in milliseconds. */
52-
latencyMs: z.number().nonnegative().optional(),
52+
latencyMs: z.number().nonnegative().optional().describe('Wall-clock latency in milliseconds'),
5353
/** ISO-8601 timestamp of the call. */
5454
timestamp: z.string().datetime().optional(),
5555
}));

0 commit comments

Comments
 (0)