Commit 4844840
feat(spec): refuse a duration key whose JSDoc names a unit its describe does not (#17635)
Part of #15939
The `check:duration-unit-keys` widening, landing **last** as ruling A
sequenced it. All seven remediation cards (#17780–#17786) have merged,
so the population this rule adds is already remediated and the widened
gate reads **zero offenders** on the merged tree.
## The ruling this executes
Option **2** of #15939, recorded 2026-09-07 (decision batch #65),
maintainer 「同意」; sequencing set by **ruling A**, director seat
2026-09-11, maintainer 「同意」 (decision batch #115):
> **Ruling A.** Batch #65's direction stands (the gate refuses a
duration key whose JSDoc names a unit its describe does not).
Sequencing: the remediation lands **first, per file** … and PR
**#17635** (the gate + self-test + the two corrected prose sites) lands
**last**, into a tree it already reads as clean. ⛔ Not B (18
published-key renames in one PR); ⛔ C is gate weakening.
JSDoc is developer commentary and is **not** "prose" in the sense of the
#14478 rule, so the gate does **not** start reading JSDoc as a unit
channel — option 1 was explicitly not adopted. What it refuses is the
**divergence**: a duration-shaped numeric key whose JSDoc names a unit
and whose `.describe()` names none (or which has no describe at all).
⛔ **The JSDoc is read in exactly one direction: to refuse, never to
satisfy.** A key with no unit in *either* channel is still listed and
still not judged (the #14519 shape, unmoved). Two self-test cases pin
that direction and neither was relaxed to reach zero:
```
✓ the JSDoc does NOT satisfy the rule: describe names the unit, name does not → still unit-in-prose-not-in-name
✓ listed, not judged: a JSDoc that names no unit leaves the #14519 shape exactly where it was
```
## What changed while this PR waited
Merged `origin/main` in (⛔ never rebased, never force-pushed — the
branch is `os-bill`'s). Merge base **`b06b2db5c4`**, two commits past
the `6d647858b7` the dispatch recorded; both land in
`packages/spec/src/` and neither introduces an offender.
**The diff is now 4 files, not 5.** #17635's `metrics.test.ts` half was
**obsolete, not merely conflicted**: #17783 rewrote the enclosing
`it(...)`, moved the same correction into a narrowed header comment, and
renamed the key itself. Verified by content rather than line number —
the hunk's exact target text (`outside the gate population entirely and
keeps its bare name`) returns **0** occurrences on the merged tree,
while the bare phrase `outside the gate population entirely` returns
**1**, now quoted as the *superseded* reading. Lit control `burn-rate`
**2**, dark control (token invented at read time) **0**. That half is
dropped; nothing is lost.
**`packages/spec/src/migrations/registry.ts` is generated and was NOT
resolved textually.** It text-merged without a conflict, which is
exactly the state AGENTS.md §10 says never to trust, so it was
regenerated with `pnpm gen:migration-registry` and proved byte-identical
to the merged bytes (blob `cef78f2140` before and after).
`check:migration-registry` then read:
```
✓ src/migrations/registry.ts is current (215 semantic, 186 retired-key, 178 retired-def)
```
The only conflict was `metrics.test.ts`, resolved to main's side.
`os-regen-merge.sh` stopped with *"✗ merge stopped on conflicts in
NON-generated files — resolve those by hand"* — **correct for this
merge**, since the sole conflict really was a hand-written file. The
#18047 misclassification did not manifest here.
## The prose repair — one site, not two
The blindness did not merely miss keys, it produced confident wrong
prose about why they were missed. One site still carried it. **The
replacement text #17635 was carrying had itself gone stale** and would
have landed already wrong: it described the neighbouring
`RuntimeConfig.resourceLimits.timeout` as a key whose "describe names no
unit at all", "inside the gate's census and outside its verdict", and "a
rename waiting on that gate change". On this tree that key is **already
renamed** — `timeoutMs … .describe('Maximum execution time in
milliseconds')` at `plugin-security-advanced.zod.ts:316-317`, its
tombstone at `:320`, its retirement prose at `:185`.
The note now repairs the *original* wrong reason without re-asserting a
landed rename as pending, and points at the neighbour's own entry
instead of restating its story. It spells no package version, so it does
not join the `@objectstack/spec 18` class filed as #18040. `registry.ts`
regenerated to mirror it. ⛔ No pin assertion, title or body changed.
The second site — the `metrics.test.ts` burn-rate pin — was corrected by
#17783 when it renamed that key, so nothing is owed there.
## The gate reading, from the right instrument
⛔ `check:duration-unit-keys` on plain `main` proves nothing here: `main`
carries the *old* gate. This is **this branch's widened gate on the
merged tree**:
```
✓ check:duration-unit-keys — 211 duration-shaped numeric key(s) across 2482 source file(s) all carry
their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared
`EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)); zero offenders, no baseline.
exit 0
```
**A zero is only as good as the proof the instrument can still say
non-zero.** Lit control, drawn a different way than the self-test — a
real on-disk mutation of a real source file, not a synthetic fixture:
the unit was stripped from `resourceLimits.timeoutMs`'s describe,
leaving its JSDoc naming milliseconds. Blob `160ab186` → `b7d06799`,
deleted anchor `0` / injected anchor `1`. The gate then:
```
✗ check:duration-unit-keys — 1 offender(s) among 211 duration-shaped numeric key(s) in 2482 source file(s)
[unit-in-jsdoc-not-in-describe] packages/spec/src/kernel/plugin-security-advanced.zod.ts:316 `timeoutMs`
— the JSDoc above the key names ms but the describe names no unit ("Maximum execution time").
exit 1
```
The **new** rule class fires, on the real tree walk, at the exact
mutated key, with the census unchanged at 211/2482 — so only the verdict
moved. Restored and proved restored by observed state: on-disk blob back
to `160ab186`, `git diff HEAD` empty, working tree clean. The mutation
ran under a `trap … EXIT INT TERM` with an absolute path.
**Population, measured here rather than relayed:** 18 renames each
carrying a `retiredKey()` tombstone entry naming #15939 on
`origin/main`, plus 3 describe-only corrections = **21**. Counted with
an instrument independent of the gate (tombstone entry files), lit
control 15 (`#15678` entries), dark control 0.
## Ruling A's objectui pre-check — inapplicable, and why
Ruling A requires a `git grep` of `objectui` at the pinned SHA before
landing a **published-key rename** (AGENTS.md Post-Task Checklist step
4). **This PR renames nothing.** The 18 renames all landed in the seven
remediation cards, each of which owed that check on its own landing.
Measured here: every changed line under `packages/spec/src/` is a
**comment** — the non-comment changed-line set is empty. So the check is
inapplicable rather than done, and it is ⛔ not silently skipped.
## Verification
Heavy runs through `scripts/pm/os-verify-lock.sh`
(`OS_VERIFY_LOCK_SLOT=issue-15939-dev`), verdicts quoted from the lock's
own `VERDICT command-exit` line, ⛔ never a bare `$?`.
| check | result |
|:--|:--|
| `pnpm --filter '@objectstack/spec^...' build` | `VERDICT command-exit
0` — **empty closure** (`No projects matched`); `packages/spec` has no
workspace dependencies, so this is a declared no-op, ⛔ not counted as a
pass |
| `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0`
(199s) — run before every gate that reads `dist/` |
| `pnpm --filter @objectstack/spec typecheck` | `VERDICT command-exit 0`
— `tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck` |
| `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` —
**476 files / 13578 tests passed** |
| `check:duration-unit-keys --self-test` | exit 0 — **156 cases, 0
failures**; the two one-directional pins quoted above intact |
| `check:duration-unit-keys` (widened, merged tree) | **exit 0, zero
offenders**, + the lit/dark controls above |
| `check:migration-registry` | exit 0 — *"registry.ts is current (215
semantic, 186 retired-key, 178 retired-def)"* |
| `check:generated` | exit 0 — **all 15** generated artifacts up to date
|
| `dispatch-gates.mjs --ran` | **82 derived, 81 run, 1 NOT-MEASURED, 0
UNRUN** |
Gate family derived **after** the merge and changeset existed, with
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, and reconciled with `--ran` carrying a
recorded exit code per family. Two prerequisites were built rather than
counted green (`@objectstack/formula`, `@objectstack/lint`,
`@objectstack/objectql`), after which `check:doc-formula-expressions`
and `check:lean-entry-closure` both read exit 0.
**⊘ NOT MEASURED — declared, ⛔ not counted green:**
- `pnpm check:dual-build-cjs-loads` — **exit 3, `PREREQUISITE NOT
MET`**: reads built output for 87 packages and needs a whole-repo `pnpm
build`. That is CI's `Build Core`. Nothing was measured; this is neither
a pass nor a finding.
- `pnpm check:pm-dispatch-gates` — **exit 124**, my own `timeout` kill
at 560s under the container's foreground cap, ⛔ not a gate verdict. Its
partial output showed no failing case, but no verdict line was reached.
Note the reconciler derives NOT-MEASURED only from exit 3, so it counts
this family as run; it is declared here instead.
The repo-wide farm (`pnpm lint` and the 48 artifact-roster, 11
wide-population and 5 path-scheduled CI families the derivation names as
outside its total) is CI's run. This narrowing is declared, not silent.
## Changeset
`.changeset/15939-duration-unit-keys-jsdoc-divergence.md`,
`@objectstack/spec: patch`. Rewritten against the merged tree: it had
claimed a live *"0 → 21 offenders"* population delta and *"two
wrongly-recorded reasons repaired"*, both false now that the remediation
has landed and one of the two sites belongs to #17783. ⛔ No published
key, accept set, default or runtime behaviour moves in this diff.
---
## Gate declaration for this PR
- **Clause-②: no**
**Why `no` is the honest answer for THIS diff**, re-measured after the
merge: the four files are the gate script (`packages/spec/scripts/**`,
not `src/**`), one comment-only prose repair, the regenerated migration
registry mirroring it, and the changeset. Mechanically checked — **every
changed line under `packages/spec/src/` is a comment**; the non-comment
changed-line set is empty. No new exported symbol, no new key on a
published payload, and no accept/reject outcome moved. What the diff
does is make an existing gate refuse *more* inputs, which **narrows**
the accept set; narrowing is the semantic face, never clause ②.
`check:pm-widening-tells` reads exit 0.
1 parent 9d288d4 commit 4844840
4 files changed
Lines changed: 261 additions & 11 deletions
File tree
- .changeset
- packages/spec
- scripts
- src/migrations
- entries/retired-keys
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
70 | 108 | | |
71 | 109 | | |
72 | 110 | | |
| |||
304 | 342 | | |
305 | 343 | | |
306 | 344 | | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
307 | 349 | | |
308 | 350 | | |
309 | 351 | | |
| |||
320 | 362 | | |
321 | 363 | | |
322 | 364 | | |
323 | | - | |
| 365 | + | |
| 366 | + | |
324 | 367 | | |
325 | 368 | | |
326 | 369 | | |
| |||
467 | 510 | | |
468 | 511 | | |
469 | 512 | | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
470 | 538 | | |
471 | 539 | | |
472 | 540 | | |
| |||
486 | 554 | | |
487 | 555 | | |
488 | 556 | | |
| 557 | + | |
489 | 558 | | |
490 | 559 | | |
491 | 560 | | |
492 | 561 | | |
493 | 562 | | |
494 | 563 | | |
| 564 | + | |
| 565 | + | |
495 | 566 | | |
496 | 567 | | |
497 | 568 | | |
| |||
561 | 632 | | |
562 | 633 | | |
563 | 634 | | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
564 | 661 | | |
565 | 662 | | |
566 | 663 | | |
| |||
773 | 870 | | |
774 | 871 | | |
775 | 872 | | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
776 | 955 | | |
777 | 956 | | |
778 | 957 | | |
| |||
894 | 1073 | | |
895 | 1074 | | |
896 | 1075 | | |
| 1076 | + | |
897 | 1077 | | |
898 | 1078 | | |
899 | 1079 | | |
| |||
Lines changed: 13 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
8 | | - | |
9 | | - | |
10 | | - | |
11 | | - | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
12 | 20 | | |
13 | 21 | | |
0 commit comments