Skip to content

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

Description

@claude

Observation filed from stack card 4/6 of #14478 (PR #15938). Not fixed there — it is outside that card's population and touches the gate's own reading surface, which card 6/6 owns.

The shape

check:duration-unit-keys reads a key's unit prose from its .describe() (and from .meta({ description })), and its own self-test pins that behaviour: "a describe declared through .meta({ description }) is READ — no exemption by blindness". It does not read the JSDoc block immediately above the key.

So a duration-shaped z.number() whose name carries no unit, and whose unit is stated only in its JSDoc, is invisible to the population. It is not exempt by either declared structural exemption (EpochMs, externalVocabulary) — it simply never enters the count. The rule's own premise is that the unit must not live in prose alone; here it lives in a less discoverable prose channel than the one the rule already rejects.

Three verified instances, read rather than pattern-matched

Measured on claude/issue-15679-system-duration-unit-in-key-name @ 45a35896c:

site JSDoc says .describe() says
packages/spec/src/kernel/plugin-security-advanced.zod.ts:294 timeout "Execution timeout in milliseconds" 'Maximum execution time' — no unit
packages/spec/src/system/metrics.zod.ts:552 window "Window size in seconds" 'Window size' — no unit
packages/spec/src/system/metrics.zod.ts:626 interval "Export interval in seconds" no .describe() at all

Why the first one is the sharp end

RuntimeConfig.resourceLimits.timeout is not a hypothetical. Card 3/6 (#15678) reasoned about it explicitly and pinned it as out of population, recording in its changeset that the key "names no unit anywhere in its prose". That reading is measurably imprecise — the JSDoc two lines above it names milliseconds. The conclusion (leave it alone, it is outside the gate's population) is still correct as a statement about the gate, but the reason recorded for a future author is wrong, and a pin test now holds it in place.

That is the cost of the blindness: it does not just miss keys, it produces confident wrong prose about why they were missed.

Not a duplicate of #15642

#15642 reports that the gate scans packages/spec/src/** only, so the same offender shape survives in other workspace packages. That is about the scanned subtree. This is about which prose channel is read inside a file the gate already scans. Both could be true and fixed independently.

Sizing — deliberately not asserted

A crude heuristic scan of packages/spec/src/**/*.zod.ts (duration-shaped numeric key, no unit token in the name, no unit in its describe, unit present in the JSDoc above) returns 60 candidate sites. That number is not a population count: reading a sample shows it includes clear false positives — byte and percentage measurements, counts, and keys that already carry a unit token. The three rows above are the ones actually read and confirmed. The real count needs the gate's own predicate applied to the JSDoc channel, which is the fix, not the triage.

Options, for whoever picks this up

  1. Read the JSDoc as a prose channel too. Most faithful to the rule's premise; expands the population, so it wants its own card and its own before/after count.
  2. Refuse a duration-shaped key whose JSDoc names a unit its describe does not. Narrower: it treats the divergence itself as the defect, and pushes authors to move the unit into the describe, where the gate and the reference pages both see it.
  3. Leave it and correct the prose. Fix card 3/6's pin note so the recorded reason is true, and accept the blindness as declared behaviour.

I have no recommendation between these — the choice is a rule change and belongs with the maintainer ruling that shaped #14478.

Ruling A execution — remediation cards filed, this card now blocks on them

Filed by the domain:spec execution seat, 2026-09-12T04:16Z, per the director seat's Ruling A (2026-09-11T14:08Z, maintainer 「同意」, decision batch #115). The 21-row delta from PR #17635 is split per file exactly as that ruling enumerated:

Blocked-by: #17780
Blocked-by: #17781
Blocked-by: #17782
Blocked-by: #17783
Blocked-by: #17784
Blocked-by: #17785
Blocked-by: #17786

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 rename rows + 3 describe-only rows = the full delta. PR #17635 lands LAST, once all seven are on main and the gate reads 0 offenders.

domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · 2026-09-12T04:16Z


Epic delegation — this subtree now runs under a dedicated PM session

Maintainer instruction, quoted verbatim (relayed by the outgoing domain:spec execution seat in its stand-down comment, 2026-09-13T07:12Z):

15939 我会交给新的项目经理开发

  • Delegated PM session: session_015c5G6TmpMKgnusmTpD7Ntt — epic PM for this subtree, seated 2026-09-13T07:24Z.
  • Reservation: pm:epic on this parent and on all seven sub-issues; pm:queue stripped from the seven in the same label write. ⛔ No other PM seat takes this parent or any sub-issue while pm:epic stands.
  • pm:blocked stays on this parent, and the seven Blocked-by: lines above are unchanged — they are the machine-greppable reverse index the unlock scan reads, and the GitHub sub-issue links do not replace them. Both coexist deliberately.

Declared file territory

CLOSED OUT 2026-09-13T18:45Z — this territory reserves nothing. The epic finished: all seven sub-issues merged, PR #17635 (the gate) merged as 484484041a, and the pm:epic reservation was released from this parent and from all seven children in the same close-out. The table below is kept as the record of how the work was partitioned, ⛔ not as a live claim on any file. See the closing summary comment for the acceptance readings and for what was carried out of the epic.

card territory
#17780 packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts
#17781 packages/spec/src/kernel/plugin-security-advanced.zod.ts
#17782 packages/spec/src/system/logging.zod.ts
#17783 packages/spec/src/system/metrics.zod.ts
#17784 packages/spec/src/system/tenant.zod.ts
#17785 packages/spec/src/system/tracing.zod.ts
#17786 packages/spec/src/kernel/plugin-loading.zod.ts, packages/spec/src/system/app-install.zod.ts, packages/spec/src/system/deploy-bundle.zod.ts
shared (six rename cards) packages/spec/src/migrations/entries/retired-keys/** + the generated packages/spec/src/migrations/registry.ts
last (PR #17635) packages/spec/scripts/check-duration-unit-keys.ts + its self-test
all .changeset/**

⚠️ The generated migration registry is the serialisation point. Every rename adds an ADR-0087 conversion and a retired-key tombstone, so all six rename cards move packages/spec/src/migrations/registry.ts. They may be authored in parallel but must land serially, each regenerated with pnpm --filter @objectstack/spec gen:migration-registry — ⛔ never hand-merged.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions