Skip to content

feat(spec): refuse a duration key whose JSDoc names a unit its describe does not - #17635

Merged
zhuangjianguo merged 7 commits into
mainfrom
claude/issue-15939-duration-unit-keys-jsdoc
Sep 13, 2026
Merged

zhuangjianguo merged 7 commits into
mainfrom
claude/issue-15939-duration-unit-keys-jsdoc

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

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 renamedtimeoutMs … .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 160ab186b7d06799, 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 0empty 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 0tsc --noEmit + check:scripts-typecheck + check:test-typecheck
pnpm --filter @objectstack/spec test VERDICT command-exit 0476 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-loadsexit 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-gatesexit 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.

⚠️ The path leg of the enqueue gate still fires (three of the paths are under packages/spec/), so an in-seat contract review at CONTRACT_REVIEW_TIER is owed before this may turn ready or enqueue. Scheduled, not waived — and this round ⛔ did not turn it ready, ⛔ did not enqueue it, and ⛔ did not touch needs:contract-review.

Prepared by the os-dev seat in session session_015c5G6TmpMKgnusmTpD7Ntt; merge commit d9b3fd5a3d, merge base b06b2db5c4.


Generated by Claude Code

…be does not

`check:duration-unit-keys` read a key's unit from `.describe()` and
`.meta({ description })` only. A duration-shaped `z.number()` whose unit was
stated solely in the JSDoc block above it was a census row with `[prose: -]`
and no verdict — and the blindness did not merely miss such keys, it produced
confident wrong prose about why they were missed.

Ruled 2026-09-07 (decision batch #65): JSDoc is developer commentary, not
governed prose, so the gate does NOT start reading it as a unit channel. What
it refuses is the DIVERGENCE — JSDoc names a unit, describe names none (or
there is no describe at all) — as `unit-in-jsdoc-not-in-describe`. The JSDoc is
read in one direction only: to refuse, never to satisfy. A key with no unit in
either channel stays listed and not judged, unchanged.

The self-test pins the three measured sites as positive controls, a
unit-in-both-channels key as the negative control, and the two ways the reader
could over-fire: a `//` line comment is not a JSDoc block, and an enclosing
declaration's JSDoc is not inherited by the first property inside it.

Also repairs the two remaining imprecise recorded reasons this blindness
produced, both comment-only: the retired-key entry for
`SandboxConfig:process.timeout` and the burn-rate `window` pin comment in
`metrics.test.ts` both said "outside the gate's population" where the truth is
"inside the census, outside the verdict", and both now name the unpublished
JSDoc unit. `registry.ts` regenerated to mirror the entry.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e248c4d688ba46fc86af199cce7660b55b8cfb30packageMentionDocs.

Which tree this was computed on

This run read content/docs from c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8 — the merge of head 605b7f60a4d0ce228a6e61aff748c9de91b8dfea into base e248c4d688ba46fc86af199cce7660b55b8cfb30, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8 && git checkout c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e248c4d688ba46fc86af199cce7660b55b8cfb30 605b7f60a4d0ce228a6e61aff748c9de91b8dfea && git checkout -B drift-repro e248c4d688ba46fc86af199cce7660b55b8cfb30 && git merge --no-ff 605b7f60a4d0ce228a6e61aff748c9de91b8dfea

node scripts/docs-audit/affected-docs.mjs --json e248c4d688ba46fc86af199cce7660b55b8cfb30

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 11, 2026
This was referenced Sep 11, 2026

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status update — the blocker CHANGED on 2026-09-11T14:08Z and this PR's body never said so

domain:spec execution seat, 2026-09-12T06:5xZ. ⚠️ Posted because a reader of this PR is currently told the wrong thing about why it is parked.

What the body still says, and why it is stale

The body's closing line reads "Its substantive blocker is unchanged and is a maintainer question — see the decision card on #15939." That is no longer true. The decision was made: the director seat recorded Ruling A on #15939 at 2026-09-11T14:08Z (batch #115), carrying the maintainer's own 「同意」.

⇒ this PR is not waiting on a maintainer. It is waiting on work, and that work is now filed.

The red is still by construction — that part is unchanged

check:duration-unit-keys exits 1 with 21 offenders on this branch, because this PR is the gate that starts refusing them. ⛔ No offender was exempted, allowlisted or silenced, and no baseline was added (the gate has none, by ruling). ⚠️ Reading that red as a defect would be wrong; reading it as "nothing to do" would also be wrong.

What actually blocks it now

Ruling A ordered the remediation first, per file, with this gate landing last into a tree it already reads as clean — which is the recommendation this PR's own body made. That is now executed:

card file rows
#17780 kernel/plugin-lifecycle-advanced.zod.ts 3
#17781 kernel/plugin-security-advanced.zod.ts 1
#17782 system/logging.zod.ts 4
#17783 system/metrics.zod.ts 5
#17784 system/tenant.zod.ts 1
#17785 system/tracing.zod.ts 4
#17786 the three describe-only sites (no rename) 3
total 21

18 renames + 3 describe-only = the full delta this PR enumerated. #15939 is now pm:blocked with one Blocked-by: line per card in its body (the machine-greppable reverse index), parsed back to confirm all seven.

⚠️ The seven cards carry finding only — ⛔ no domain:*, no priority:*, because those have exactly one producer and it is not this seat. They owe triage's first touch before any of them can be dispatched.

⇒ This PR's disposition, stated plainly

Parked, draft, and correctly so — but the reason to write on it from now on is "waiting on #17780#17786", ⛔ not "waiting on a ruling". When all seven are on main: merge main here, confirm check:duration-unit-keys reads 0 offenders, and land it.

⚠️ Each of the 18 renames touches a published key, so per Ruling A's execution note every one of them must git grep the pinned objectui checkout at its pinned SHA before landing (AGENTS.md Post-Task Checklist step 4). That is the seven cards' obligation, not this PR's.

⚠️ This correction is mine to own: the ruling landed at 14:08Z on 2026-09-11 and I did not update this PR then. For a stretch afterwards I was also telling the maintainer that #15939 was still awaiting their decision, which was wrong for the same reason — I was asserting a card's state from memory instead of reading its thread to the last page.

domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · 2026-09-12T06:5xZ


Generated by Claude Code

…ration-unit-keys-jsdoc

# Conflicts:
#	packages/spec/src/system/metrics.test.ts
…ted tree

The note's replacement prose was written while the neighbouring
`RuntimeConfig.resourceLimits.timeout` rename was still pending, and asserted
that key was "inside the gate's census and outside its verdict", that its unit
was "not missing, only unpublished", and that it was "a rename waiting on that
gate change". #15939 ruling A's per-file remediation has since landed that
rename, so all three read false on this tree.

The note now repairs the original wrong reason ("outside the gate's population")
without re-asserting a landed rename as pending, and points at the neighbour's
own entry instead of restating its story. registry.ts regenerated to mirror it.

Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt
Co-authored-by: Claude <noreply@anthropic.com>
…iated tree

The changeset was written while all 21 offenders were pending. It claimed a
"0 -> 21" population delta on this tree and "two wrongly-recorded reasons
repaired". Both now read false: ruling A's seven per-file cards have landed the
whole population, so the gate reads zero offenders here, and the metrics.test.ts
half of the prose repair was carried by #17783 when it renamed that key.

Numbers re-measured on this tree: 0 offenders among 211 duration-shaped numeric
keys across 2482 source files.

Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt
Co-authored-by: Claude <noreply@anthropic.com>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review — PR #17635, in-seat clause-② review at CONTRACT_REVIEW_TIER

Head-sha: 605b7f60a4d0ce228a6e61aff748c9de91b8dfea
Implemented-by: claude/issue-15939-duration-unit-keys-jsdoc
Reviewed-by: session_015c5G6TmpMKgnusmTpD7Ntt

Adopted verbatim from a context-isolated, adversarial review subagent. Its brief carried the card, the recorded rulings and the PR itself — ⛔ not the dispatch order and ⛔ not this seat's own conclusions — and was written to invite rejection. The verdict below is reproduced whole and unedited; ⛔ this seat did not rewrite, abridge or polish any part of it, and the two out-of-scope gaps it found are carried through rather than dropped.

Served-tier verification (降档保险丝). CONTRACT_REVIEW_TIER read from origin/main:scripts/pm/dispatch-gates.mjs:10510 = claude-fable-5-1. Served tier taken from the harness-stamped transcript, ⛔ never from a self-report: 96 × claude-fable-5-1, zero other values. Discriminating control — the identical extraction over this round's os-dev transcript reads 219 × claude-opus-5, so the instrument separates tiers rather than printing whatever it is shown; dark control (a tier string invented at read time) 0.


Head reviewed: 605b7f60a4 (branch claude/issue-15939-duration-unit-keys-jsdoc, draft). Merge-base b06b2db5c4 confirmed as d9b3fd5a3d^2 and an ancestor of origin/main 6a3bcd8174 (git merge-base --is-ancestor → yes). Diff = 4 files, +261/−11 (git diff --stat b06b2db5c4 605b7f60a4). origin/main is 2 commits past the merge-base: 6 files, five *.test.ts plus scripts/pm/board-snapshot.mjs, none under packages/spec/ and none inside the gate's walk (test files are excluded by SKIP_DIRS/test-file rule, self-test case "the walk skips node_modules/, dist/, build/ and test files"). Toolchain for the runs below: typescript@6.0.3 + tsx@4.23.13 (the lockfile's pins) installed in the scratchpad; the head and control trees were detached worktrees, removed afterwards. All readings 2026-09-13, 17:20–17:47Z.

① Derived judgments

Accept-set / public-surface changes the diff produces:

  1. Gate packages/spec/scripts/check-duration-unit-keys.ts gains rule unit-in-jsdoc-not-in-describe (judge() branch if (site.durationShaped && site.jsdocUnits.length > 0), reached only after the proseUnits.length > 0 branch has returned). Direction: refuse-only — the branch adds a Finding, never returns "compliant". Verified by fixture on the head script (collectDurationKeys+judge imported directly): timeoutMs + JSDoc "milliseconds" + describe "Maximum execution time" → unit-in-jsdoc-not-in-describe; same with no describe at all → refused; durationMs (spec: three duration keys already name their unit in the key but not in .describe() — #15939 Ruling A remediation (the 3 describe-only rows of the 21-row delta) #17786 shape) → refused. This narrows what the gate accepts; it is not a widening. Right. Not a published surface: packages/spec/package.json files = dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json — no scripts/; no importer of the script anywhere (grep -rn check-duration-unit-keys over *.ts,*.mts,*.mjs,*.json,*.yml,*.md → only package.json:291, prose mentions in registry.ts:8671 / one semantic entry / workspace-enumerator.d.mts comment, CHANGELOG.md:983).
  2. export interface DurationKey +jsdoc, +jsdocUnits; Finding.rule union +1 member; --list rows gain [jsdoc: …]. Exported symbols of a non-published, un-imported script. Not a public surface. Right.
  3. Accept/reject of any published schema, any default, any runtime behaviour: unchanged. Changed lines under packages/spec/src/ that are not // comments: 0 (git diff b06b2db5c4 605b7f60a4 -- packages/spec/src | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -vE '^[+-]\s*//' | wc -l); lit control, comment lines: 36. node scripts/pm/check-widening-tells.mjs --declaration no --diff pr.diff → exit 0, no tell (2 files judged, 2 NOT MEASURED: the script and the changeset, which have no declared surface). node scripts/pm/check-clause2-carriers.mjs --pair 17635 → exit 0, both carriers agree.
  4. Published prose channels: none moved. The rewritten comment lives in entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts (not a .zod.ts, so not in the tarball as source) and its mirror in registry.ts. docs/protocol-upgrade-guide.md renders protocol steps 10→11 … 16→17 only (grep -nE '^## ' docs/protocol-upgrade-guide.md); kernel-plugin-security-durations-unit-in-key and kernel-runtime-config-timeout-unit-in-key occur 0 times there, lit control authoring-schemas-strict-unknown-keys 1. Old phrase outside the gate's population under docs/ + content/: 0 (the 3 tree hits are the new comment quoting it and its registry mirror). content/docs/references/kernel/plugin-security-advanced.mdx:425 renders the .describe() (Process timeout in ms), not comments. Dark control qxv-tombstone-9917 → 0. Right.
  5. Registry regeneration is faithful. tsx scripts/build-migration-registry.ts --self-test --check✓ src/migrations/registry.ts is current (215 semantic, 186 retired-key, 178 retired-def); regenerating rewrote the file to the same blob d531512b72 (worktree status empty); the 18 comment lines of the entry equal the 18 registry lines after stripping the 4-space indent (Python compare → True). Not hand-edited. Right.
  6. Neighbour claims in the rewritten comment are true on this tree. plugin-security-advanced.zod.ts at head: timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time in milliseconds') at :316–317, timeout: retiredKey(RUNTIME_RESOURCE_LIMITS_TIMEOUT_RETIRED) at :320, JSDoc "Execution timeout in milliseconds" above :316; entry file 18.kernel__RuntimeConfig__resourceLimits.timeout.ts exists; id kernel-runtime-config-timeout-unit-in-key at entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts:6 and registry.ts:8849; kernel-plugin-security-durations-unit-in-key defined at entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts:6. Dark control (invented) kernel-runtime-config-timeout-unit-in-jsdoc-zq → 0. "No D2 conversion" sentence: git grep -l '#15939' 605b7f60a4 -- packages/spec/src/conversions → 0 (lit: 11 files in that dir). Right.
  7. Version axis. Added lines carry no @objectstack/spec <N> token (grep -E '^\+' pr.diff | grep -oE '@objectstack/spec [0-9]+' → empty); lit control on the tree with '…' + '…' literals joined: 5 × @objectstack/spec 17 in plugin-security-advanced.zod.ts. Pending changesets for @objectstack/spec on head: 54 minor, 55 patch, 0 major; package is 17.4.0 ⇒ this ships in 17.5.0. The 18. filename prefix is RETIRED_KEYS_BY_MAJOR[18], the protocol axis, untouched. Right.
  8. Population claim (21, remediated ahead, zero added) — reproduced with two instruments independent of the head-tree gate run. (a) Head gate script run on the pre-remediation tree 690f083f83 (parent of the first PR commit 0b9f8dbbb9): exit 1, 21 offender(s) among 211 duration-shaped numeric key(s) in 2433 source file(s), all 21 [unit-in-jsdoc-not-in-describe], at exactly the 18 rename sites + plugin-loading.zod.ts:104 durationMs, app-install.zod.ts:146 durationMs, deploy-bundle.zod.ts:129 estimatedDurationMs; the tree's own old gate on the same tree: exit 0, zero (blind, as the card said). On head: exit 0, 211 / 2482 / 6 EpochMs / 11 externalVocabulary, zero offenders. Census 211/6/11 identical before and after ⇒ no key left the census and no exemption count grew: nothing was exempted, allowlisted or baselined; the gate has no baseline mechanism (grep -in baseline → only the "No baseline, by ruling" header; no *baseline* file names this gate). (b) Tombstone entries: retired-key entry files naming #15939 on the merge-base 18 (lit control #15678 15, dark control #15939-zzq7 0); on head it reads 19 because this diff's own rewritten comment adds #15939 to the SandboxConfig entry — the instrument is clean on base, contaminated by the diff on head. Window control 165→186 entry files (+21): 18 are finding: check:duration-unit-keys reads .describe() but not JSDoc — a duration key documenting its unit only in JSDoc never enters the population, and one card already recorded a wrong reason because of it #15939's, the other 3 are 18.ui__ListView__pageName, 18.ui__ObjectKanbanProps__quickAdd, 18.ui__ObjectListView__pageName (unrelated). (c) A regex scanner independent of the gate (JSDoc directly above a z.number() key names a unit, describe does not) reads 31 on the pre tree → 11 on head; the 21 gate sites are all in the 31 and none in the 11. Right, with one caveat below.
  9. Caveat — the divergence predicate is narrower than the census it reports on. The head --list shows 2 census rows with [jsdoc: X] and [prose: -]: packages/spec/src/ai/usage.zod.ts:52 latencyMs [name: ms] [prose: -] [jsdoc: ms] null (no describe at all) and packages/spec/src/system/tenant.zod.ts:603 frequencyHours [name: hours] [prose: -] [jsdoc: hours] "Backup frequency". Both are in the 211 (via keyUnits), both are the exact shape of the three spec: three duration keys already name their unit in the key but not in .describe() — #15939 Ruling A remediation (the 3 describe-only rows of the 21-row delta) #17786 rows, and neither is refused because isDurationShaped() is a name-token list (latency, frequency absent) while the census is proseUnits>0 || durationShaped || keyUnits>0. Fixtures confirm: latencyMs shape → durationShaped=false, not refused; durationMs shape → refused. The ruling's wording ("duration-shaped numeric key") is the gate's pre-existing term of art, so the implementation follows the letter; but the changeset's "refuses the DIVERGENCE … (or there is no describe at all)" over-claims by these two rows, and the self-test's "DURATION-SHAPED only" pin uses sampleCount (a count), not the shape that actually escapes. Not a widening, not a moved outcome — a declared-narrow rule with two rows the zero does not cover. Out of this PR's landing scope by ruling A's own sequencing (remediation first, gate last); must be filed (see out-of-scope).
  10. Self-test direction pin — partial. Ablation M2 (jsdocTextOfundefined): self-test exit 1, exactly 5 red (3 positive controls, the externalVocabulary divergence, the census-carries-jsdocUnits case) ⇒ positive controls are live; an option-1 implementation would also go red because those cases assert the rule name. But mutant M1 (… && site.keyUnits.length === 0, i.e. a unit in the NAME lets the JSDoc satisfy without a describe) passes all cases (exit 0), reads 0 offenders on head, and reads 18 (not 21) on the pre tree. The two pins the body quotes ("the JSDoc does NOT satisfy the rule…", "listed, not judged…") cannot fail under M1 — they are vacuous for that back door. The real gate is correct (fixture + the body's on-disk timeoutMs control), but the guarantee is unpinned for the name-carries-unit case. Self-test prints 78 per run and "all cases pass" (58 expect( sites); the body's "156 cases" is not reproduced by one run (78 × 2 = 156 suggests a doubled count) — immaterial.

② Semver grading

patch on @objectstack/spec is the correct level and matches the changeset. The diff publishes nothing but a comment: scripts/ is outside files; the only src/ bytes changed are // lines in a non-.zod.ts entry and their mirror in registry.ts, which compiles into dist via export * from './migrations/index.js' (src/index.ts:226) with no removeComments set — so "publishes nothing from any released package" (the skip-changeset bar, AGENTS.md Post-Task step 3) is not quite met, and patch is the floor. No widening ⇒ no minor (AGENTS.md: Clause-②: yes ⇒ ≥ minor; here it is no, correctly). Ruling A prescribed Clause-②: no / patch for the whole sequence. One wording nit in the changeset: "eighteen were renames … each carrying its own ADR-0087 conversion" — on the base tree the 18 carry 8 semantic (D3) entries grouped per file and 0 D2 conversions; the repo uses "conversion" loosely (ruling A and the gate message do too), so this is cosmetic, but it is published CHANGELOG text.

③ Boundary-flag disposition

  • Clause-②: no — correct, established by ① items 1–4 with mechanical instruments and controls. The path leg still fires (3 paths under packages/spec/), which is why this review exists; the declaration leg is honest.
  • NOT MEASURED pnpm check:dual-build-cjs-loads (exit 3) — legitimate: it needs a whole-repo build; this diff touches no runtime module. Not re-run here (no workspace install in this session; declared 17:47Z). CI Build Core on 605b7f60a4 completed success 16:57:36Z — noted as the family's own instrument, not as contract evidence.
  • NOT MEASURED pnpm check:pm-dispatch-gates (exit 124) — legitimate and creditably declared by hand where the --ran reconciler would have counted it as run; the diff touches nothing under scripts/pm/. Not re-run here.
  • Repo-wide farm declared to CI (pnpm lint, artifact-roster / wide-population / path-scheduled families) — legitimate narrowing. I could not run check:generated / check:spec-changes / check:upgrade-guide locally (Cannot find module 'zod' in the bare toolchain; declared 17:47Z); the only generated artifact this diff touches, registry.ts, I regenerated byte-identically myself, and the upgrade guide provably does not render 18.* entries (① 4). Lint & Repo Gates (which runs check:duration-unit-keys at lint.yml:4542, check:spec-changes :5012, check:upgrade-guide :5015) completed success 17:06:58Z on the head SHA; 49 check runs on 605b7f60a4, all completed, none failed.
  • Open question — take a second merge of origin/main? Option A is right: the 6 advanced files are five test files plus scripts/pm/board-snapshot.mjs, none in this diff's paths and none inside the gate's walk, so the widened gate's reading on origin/main + this diff equals the reading on head.
  • objectui pre-check declared inapplicable — correct: 0 non-comment lines under src/ ⇒ nothing renamed here.
  • metrics.test.ts half dropped as obsolete — verified on head: the hunk's target phrase outside the gate population entirely and keeps its bare name → 0; bare phrase → 1 at :563, quoted as the superseded reading; REFUSES the burn-rate pin present (1); dark control 0. Nothing lost.
  • "⛔ No offender was exempted to reach that zero" — verified (① 8: census and exemption counts identical pre/post; no baseline).
  • Prose repair "one site, not two" — the sentence-by-sentence claims on the SandboxConfig entry all hold on this tree (① 6), and the second site is spec: 5 duration key(s) in system/metrics.zod.ts name their unit only in JSDoc — #15939 Ruling A remediation (5 of the 21-row delta) #17783's (above).

PASSClause-②: no and patch are correct for this diff (0 non-comment source lines, no new published symbol or key, a gate that refuses strictly more), the 21→0 population is real and reproduced on an independent control tree with no exemption or baseline, the registry is a faithful regeneration, and the rewritten prose is true on the tree; the two gaps found are real but sit outside this PR's landing scope under ruling A's sequencing and do not move any accept/reject outcome.

Out of scope (real, must be filed):

  1. Two census rows of the ruled divergence shape escape the new rulepackages/spec/src/ai/usage.zod.ts:52 latencyMs (JSDoc "Wall-clock latency in milliseconds", no .describe() at all) and packages/spec/src/system/tenant.zod.ts:603 frequencyHours (JSDoc "Backup frequency in hours", describe "Backup frequency") — because judge() gates the divergence on isDurationShaped(name) while the census admits keyUnits. Fix shape, in ruling A's order: a describe-only card for the two (no rename; the unit is already in the name), then widen the branch to (site.durationShaped || site.keyUnits.length > 0), and add a positive control whose name carries the unit (e.g. timeoutMs + JSDoc ms + describe none → refused) so the M1 back door goes red. Tighten the changeset/CHANGELOG sentence if a follow-up changeset is written.
  2. Duration-valued keys outside the census entirely (the option-1 population the ruling declined, listed for the record, not asserted as owed): packages/spec/src/kernel/plugin-versioning.zod.ts:191 estimatedMigrationTime (JSDoc hours, no describe), packages/spec/src/system/metrics.zod.ts:847 resolution (JSDoc seconds, describe "Downsampled resolution"); and four z.number() epoch timestamps whose JSDoc says milliseconds but which are not typed EpochMs (data/document.zod.ts:35 createdAt, :402 expiresAt, system/supplier-security.zod.ts:146 assessedAt, :151 validUntil) — a [#14478 stack 1/6] declare the two exemption classes ON THE SCHEMA — a shared EpochMs for the 6 epoch instants and a .meta({ externalVocabulary }) marker on the 13 external-standard keys, honoured by check:duration-unit-keys and printed by the docs generator #15676 ruling-B shape, not this card's.
  3. JSDoc-vs-describe unit contradiction is not a class (timeoutMs + JSDoc "seconds" + describe "milliseconds" → not refused). Consistent with batch Add comprehensive test coverage for ObjectStack spec protocols - 100% coverage achieved #65 (JSDoc is not governed prose); noted only.
  4. tests label on the PR is stale — the diff no longer touches a test file. Label write is the seat's.

epic PM for #15939 · session_015c5G6TmpMKgnusmTpD7Ntt · 2026-09-13T17:55Z


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 13, 2026 17:52
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 4844840 Sep 13, 2026
54 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15939-duration-unit-keys-jsdoc branch September 13, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants