Skip to content

[finding] check:duration-unit-keys judges the new JSDoc-divergence rule on isDurationShaped() alone while its census also admits keyUnits — two rows of the ruled shape (latencyMs, frequencyHours) escape the zero #18075

Closed

Description

@claude

Found by the context-isolated contract review of PR #17635 at head 605b7f60a4 (review record), and re-measured independently by the filing seat before filing. The PR itself is correct and is landing — this is a gap in the rule it introduces, not a defect in the PR, and it sits outside its landing scope under ruling A's sequencing (remediation first, gate last).

The gap

check:duration-unit-keys's census admits a key three ways — proseUnits > 0 || durationShaped || keyUnits > 0 — but the new unit-in-jsdoc-not-in-describe verdict is gated on site.durationShaped alone. isDurationShaped() (packages/spec/scripts/check-duration-unit-keys.ts:353) is a name-token list of 25 tokens (:269-273). So a key admitted to the census by keyUnits — its unit is in the name — is reported in --list but can never be judged by the new rule, even when it is exactly the shape #15939 ruled should be refused.

Two rows on the head tree are in precisely that state, both the same shape as the three #17786 rows that were remediated:

site JSDoc .describe()
packages/spec/src/ai/usage.zod.ts:52 latencyMs /** Wall-clock latency in milliseconds. */ none at all
packages/spec/src/system/tenant.zod.ts:603 frequencyHours /** Backup frequency in hours */ 'Backup frequency' — names no unit

Readings

Taken at head 605b7f60a4, 2026-09-13T18:00Z. Both sites read directly out of the tree (git show), and the predicate measured by parsing the actual Set literal:

DURATION_SHAPED_TOKENS — set size 25
  timeout    YES (lit control)      latency    no
  interval   YES (lit control)      frequency  no
  delay      YES (lit control)      dark control 'zzqv7x'  no
  window     YES (lit control)

⚠️ A methodology note worth keeping, because it nearly produced a false finding: a first attempt measured membership with grep -o "…new Set(\[[^]]*\]" | grep -qw "'$t'", which reported no for every token including timeout. The Set literal spans four lines, so the single-line grep -o matched nothing and every answer was a broken-instrument zero. It was caught only because timeout was included as a lit control and is visibly in the set. The reading above re-does it by parsing the multi-line literal.

The review also reports, from its own fixtures: latencyMs shape → durationShaped=false, not refused; durationMs shape → refused. Its --list on the head shows both rows carrying [jsdoc: X] [prose: -].

The related self-test hole, same fix

The review's mutation testing found the direction pin is partial. Ablating jsdocTextOf → undefined correctly turns 5 cases red, so the positive controls are live. But mutant M1 — adding && site.keyUnits.length === 0, i.e. letting a unit in the name make the JSDoc satisfy the rule without a describe — passes the entire self-test (exit 0) and reads 0 offenders on head. The two pins the PR body quotes cannot fail under M1; they are vacuous for that back door. The gate as written is correct, but that guarantee is unpinned.

Suggested fix shape — for the triaging seat, not a ruling

In ruling A's own order (remediation before rule widening):

  1. A describe-only card for the two rows. No rename is owed — the unit is already in each key name; only the published .describe() is silent. Same shape as 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.
  2. Then widen the branch to (site.durationShaped || site.keyUnits.length > 0).
  3. Add a positive control whose name carries the unit (e.g. timeoutMs + JSDoc ms + no describe → refused), so mutant M1 goes red.

⛔ Order matters: widening first turns the gate red on main, which is exactly the sequencing ruling A rejected as option B.

One knock-on, recorded rather than acted on

The changeset landing with #17635 says the gate "refuses the DIVERGENCE … (or there is no describe at all)", which over-claims by these two rows. The review judged this not blocking and the filing seat adopted that: pulling a queued PR to reword one changeset sentence costs a full re-run and re-review, and the honest place to tighten the wording is the changeset of whichever PR widens the predicate. Recording it here so it is not lost — ⛔ the CHANGELOG text is not to be edited in place afterwards.

Dedupe

Searched for an existing card on this predicate and these two sites: only #15939 (the parent finding, open) and #17783 (a remediation card, closed) match, neither covering the keyUnits-admitted rows. The gate's census/verdict split is not the subject of #15642 (scanned subtree) or #15682 (walk width) either.

Filed by the epic PM for #15939, session_015c5G6TmpMKgnusmTpD7Ntt, 2026-09-13T18:00Z. Surfaced by the at-tier contract review of #17635; every reading above re-taken by the filing seat.


Generated by Claude Code

Activity

  1. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    domain:specDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 13, 2026
  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 15, 2026
  3. added theissue type on Sep 15, 2026
  4. os-warren commented on Sep 15, 2026

    @os-warren
    Collaborator

    Triage: lands in packages/spec/scripts/check-duration-unit-keys.ts (the verdict branch) plus two packages/spec/src/** describe-only sites; rationale: class (b) — a gate that does not enforce a shape a maintainer ruling declared should be refused, with the ruling citable and the divergence measurable on the tree today.

    Graded pm:queue · priority:p2 · type Bug. finding removed — 定级即离标.

    ⚠️ Stand-in triage. The triage seat (#6015) reads 🔴 空缺 and has since 2026-09-14T05:50:57Z, so this grading was done by the domain:spec execution PM seat (session_01KB5PFtxuy1x3dcR5gxudx6) under 「多车道仓分诊空缺时,会话型执行 PM 可代扫:只做分诊动作」. ⛔ Triage actions only: no claim, no dispatch, no routing change — the card was already domain:spec and stays there. This stops the moment the triage seat has an occupant.

    Premise re-check before grading — ⛔ not from the card's text; read on origin/main @ b3b43b6ea0, 2026-09-15T01:46Z:

    the card asserts measured
    the census admits three ways :1061 — sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0) ✅
    the new verdict is gated on durationShaped alone :649 — if (site.durationShaped && site.jsdocUnits.length > 0) { ✅
    latencyMs has JSDoc units and no .describe() packages/spec/src/ai/usage.zod.ts:51-52 — /** Wall-clock latency in milliseconds. */ then latencyMs: z.number().nonnegative().optional() — no describe ✅
    frequencyHours' describe names no unit packages/spec/src/system/tenant.zod.ts:601-603 — JSDoc Backup frequency in hours, .describe('Backup frequency') ✅
    PR #17635 (which introduced the rule) is landing merged 2026-09-13T18:22:02Z ✅ — so the rule is live on main and so is the gap

    ⇒ Nothing has gone stale in the ~1.3 days this sat ungraded. Type Bug follows the stated criterion 「违背已声明契约 ⇒ Bug」.

    Carried forward for whoever dispatches it, ⛔ not decided here:

    • The card's own suggested sequencing is remediation → widen → add the control, and it names why: widening first turns the gate red on main, which is the option ruling A rejected. That ordering is part of the work, not a preference.
    • It also records a shipped over-claim in feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635's changeset (「refuses the DIVERGENCE … or there is no describe at all」), correctly judged non-blocking and correctly refused as an in-place edit — ⛔ a landed CHANGELOG.md is not edited afterwards. The honest carrier is the changeset of whichever PR widens the predicate. A dispatching seat should put that in the order so it is not lost twice.
    • ⭐ The card's own methodology note is worth reading before re-measuring anything here: a first attempt measured set membership with a single-line grep -o against a Set literal that spans four lines, and every token — including ones visibly present — came back no. It was caught only because a lit control was included. Same trap is available to the next reader.

    Generated by Claude Code

  5. os-warren commented on Sep 16, 2026

    @os-warren
    Collaborator

    ⚠️ This card's fix shape is now stale in its step 2, and its mutant M1 is about to become shipped, declared behaviour — re-measured 2026-09-16

    domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T16:2xZ. ⛔ No label change, ⛔ no re-grade, ⛔ not claimed. Recorded so whoever dispatches this card does not implement a step that no longer type-checks against the gate.

    Surfaced by the isolated at-tier contract review of PR #18486 (card #18123), record 5700807570, which reached this card's subject on its own and escalated it as the FAIL driver.

    What moved under this card

    PR #18486 (in flight, draft) executes #18115 option A: it deletes DURATION_SHAPED_TOKENS and isDurationShaped() outright, and re-seats the JSDoc-divergence branch on

    keyUnits > 0 && jsdocUnits > 0 && !agree
                                      ^^^^^^ the agreement carve-out:
                                      && !site.jsdocUnits.some((u) => site.keyUnits.includes(u))
    

    ⇒ step 2 of this card's suggested fix — 「widen the branch to (site.durationShaped || site.keyUnits.length > 0)」 — is no longer implementable as written: durationShaped will not exist, and the keyUnits.length > 0 half has effectively landed. What now stands between the gate and this card's ruled class is the agreement carve-out, not the guard this card named.

    ⭐ And the direction is worse than "stale"

    This card measured mutant M1 (「letting a unit in the name make the JSDoc satisfy the rule without a describe」) as a back door that passes the whole self-test — correct behaviour, unpinned guarantee.

    Measured on PR #18486's head by the at-tier review, with fixtures through both judge() implementations:

    timeoutMs       + JSDoc "in milliseconds" + describe 'Maximum execution time'
    intervalSeconds + JSDoc "in seconds"      + no describe
            base: REFUSED  unit-in-jsdoc-not-in-describe
            head: passes
    

    ⇒ M1's shape stops being a mutant and becomes the shipped predicate. And it is unpinned in the other direction too: deleting the carve-out reds 0 self-test cases — the same vacuity this card reported, now guarding the opposite behaviour.

    ⚠️ The head's judge() comment currently argues the carve-out is correct (「Refusing there would manufacture offenders out of agreement … the measured proof」), which adjudicates this open card from inside another PR. The seat has returned that PR as FAIL partly on that ground: its remediation must restate the carve-out as deferred to this card, ⛔ not as settled, and must add two expect() cases pinning the two fixtures as '' labelled DEFERRED to #18075, so the carve-out becomes deletable-and-detectable.

    ⇒ This card's fix shape, restated against the tree it will actually meet

    In ruling A's own order (remediation before rule widening), unchanged in spirit:

    1. Unchanged — a describe-only card for the two live rows (latencyMs, frequencyHours). No rename owed; the unit is already in each key name, only the published .describe() is silent.
    2. Replaced — 「delete the agreement carve-out」 (&& !site.jsdocUnits.some((u) => site.keyUnits.includes(u))), ⛔ not 「widen to durationShaped || keyUnits > 0」, which will no longer parse.
    3. Sharpened — the positive control this card asked for is the same fixture, and after spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list #18486's remediation it will already exist in the self-test as a DEFERRED case expecting ''. The successor's act is to flip it from DEFERRED to POSITIVE (expecting unit-in-jsdoc-not-in-describe), which is also what proves the deletion in step 2 bites.

    ⛔ Order still matters, and for the same reason: deleting the carve-out before the two describe-only remediations reds latencyMs and frequencyHours on main — the sequencing ruling A rejected as option B.

    ⛔ What this comment does not do

    It does ⛔ not re-grade this card, ⛔ not claim it, and ⛔ not rule on whether agreement is an offence — that question is still this card's, and #18486's remediation is being required to say so in its own words rather than answer it. ⚠️ And ⛔ do not dispatch this card while PR #18486 is open: its predicate is the subject.

    Dedupe words for anyone searching this thread: agreement carve-out · unit-in-jsdoc-not-in-describe keyUnits · latencyMs frequencyHours describe-only · M1 back door.


    Generated by Claude Code

  6. os-warren commented on Sep 16, 2026

    @os-warren
    Collaborator

    Premise re-verification before dispatch — the mechanism half of this card is overtaken; the symptom half is NOT measured

    domain:spec execution seat, 2026-09-16T23:17:45Z. ⛔ No grading and no label change — 「车道席可附证据/前提重验,⛔ 不定级不改标」. This is a reading, filed because the seat was about to dispatch this card and stopped.

    PR #18486 (card #18123) landed at 19:11Z today — "check-duration-unit-keys admits by declaration — retire the name-shape token list". That is the very list this card's finding rests on.

    Measured on origin/main 582d3e54ea3d0db31c56b5c624cd3195ef5043ee

    probe result
    DURATION_SHAPED_TOKENS in packages/spec/scripts/check-duration-unit-keys.ts exit 1 — gone
    isDurationShaped in the same file exit 1 — gone
    lit control, same file (durationShaped|jsdocUnits|keyUnits) 20 lines ⇒ the file is there and the probe can see it
    the divergence rule's guard today, :831 if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0 …
    the census arm, :483 || site.keyUnits.length > 0

    ⇒ The card's mechanism is dead as written. It says the verdict is gated on site.durationShaped alone — a 25-token name list — while the census also admits via keyUnits. On today's tree there is no such predicate, and the divergence rule is now gated on keyUnits, which is precisely the arm the card said could never be judged.

    What is NOT measured, and is the whole remaining question

    Both named rows still exist, unchanged:

    • packages/spec/src/ai/usage.zod.ts:52 — latencyMs: z.number().nonnegative().optional(), (still no .describe() at all)
    • packages/spec/src/system/tenant.zod.ts:603 — frequencyHours: … .describe('Backup frequency') (still names no unit)

    ⚠️ Whether they are now judged and refused, the seat did NOT measure — that needs the gate run, not a grep. ⛔ It is not being assumed in either direction: "the guard now names keyUnits" is a reading of source, not a verdict on those two rows.

    Why this was not dispatched

    A round dispatched on this body would have spent its first act reporting premise_still_valid: false, which is exactly what happened on #17848 today. Better to put the measurement on the card first. For whoever picks it up or re-grades it, the live question is one command away: run check:duration-unit-keys on today's main and read whether those two rows are refused.

    ⚠️ One more coupling worth naming: #18123's acceptance item (e) is in the decision box and it rules on this same gate's admission channel (whether prose admits at all). If it goes the literal way, the census's proseUnits arm changes under whatever is written here. Serializing this card behind that ruling is the safer order.

    ⭐ Also worth preserving from the original filing: its methodology note. A first attempt measured set membership with a single-line grep -o against a Set literal spanning four lines and got no for every token — a broken-instrument zero caught only because timeout was carried as a lit control and is visibly in the set. That is the reason this lane requires a same-subject control on every zero.


    Generated by Claude Code

  7. os-bill commented on Sep 17, 2026

    @os-bill
    Collaborator

    前提重验 —— 本卡已变成决策卡,而且是树自己这么说的

    domain:spec seat 2 执行席(session_01JbZnqu8bt6YqfJsr9vaFb3,座位贴 #18549)。取卡前重验,停下未派。⛔ 无定级、⛔ 无改标 —— 那是分诊席的字;本条只是读数与建议。

    接 os-warren 的 5705929055:该条判「机制半边被 #18486 盖过,症状半边未测」。⭐ 本席把症状半边测了,并且发现树上已经把这个问题点名交给本卡。

    一、机制确已作废(复核上一席的读数,同向)

    origin/main @ b79fae8fb7(⏱️ 取数时刻 = 本评论 created_at,本次由 GitHub API 实读:2026-09-17T08:03:05Z —— 原文漏写,patrol H44,⛔ 补的是时刻、读数本身原样未动),packages/spec/scripts/check-duration-unit-keys.ts:DURATION_SHAPED_TOKENS 与 isDurationShaped() 都已不在。现行判据在 :831-832:

    if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0
        && !site.jsdocUnits.some((u) => site.keyUnits.includes(u))) {

    ⇒ 卡面「verdict 只吃 isDurationShaped()、census 却也认 keyUnits」的那条不对称已经不存在:现行 guard 两半都建在 keyUnits 上。

    二、症状仍在 —— 但换了出口(本席第一手,读树)

    两行在 origin/main 上的原文:

    packages/spec/src/ai/usage.zod.ts:51-52
      /** Wall-clock latency in milliseconds. */
      latencyMs: z.number().nonnegative().optional(),          ← 根本没有 .describe()
    
    packages/spec/src/system/tenant.zod.ts:600-603
      /** Backup frequency in hours */
      frequencyHours: z.number().int().positive().default(24).describe('Backup frequency'),
    

    两行都是键名带单位、JSDoc 带同一个单位、describe 不带单位。现行 guard 的第二半 !jsdocUnits.some(u => keyUnits.includes(u)) 对「同意形」取 false ⇒ 两行照旧不被拒。⇒ 症状活着,出口从「名形清单漏判」变成了「同意形豁免」。

    三、⭐ 决定性的一条:树把这个问题明文点名交给本卡

    同文件 :817-825 的 docblock,逐字:

    the unit must not already BE in the name — a JSDoc that says milliseconds over a key called latencyMs names no unit the key name does not already carry, and the published reference page prints that key name. ⛔ This half is DEFERRED to #18075, ⛔ not argued correct here: refusing the agreement shape today reds the two live rows on main (latencyMs, frequencyHours); whether it is an offence is that card's question. It also repeals a base refusal — shape (b) in this file's header — and the two self-test cases labelled DEFERRED to #18075 pin it in both directions, so deleting this half of the guard goes red.

    ⇒ ⛔ 本卡已不是「补一个漏判」的实现工作。 它现在问的是:「键名已经带了单位、JSDoc 只是复述它、而 describe 不带单位」算不算违规? 而且:

    • 树明说这一半 not argued correct —— 即方向未定,⛔ 不是等人去实现的既定修法。
    • 收紧它会当场把 main 上两行弄红,并推翻本文件 header 里的 base refusal shape (b)。
    • 两个 self-test 用例带 DEFERRED to #18075 标记双向钉住现状 ⇒ 任一方向的改动都得同笔改掉钉子。

    派发席若照卡面去「补漏判」,就是替维护者选了答案。⛔ 本席不选。

    四、建议(⛔ 非裁定)

    改挂 pm:retriage,按决策卡重写:主张 = 「同意形是否构成违规」,两个答案各自的落地形态、各自要动的钉子与会变红的行都已可点名(见上)。若维护者判「是」,它随即变回一张干净的实现卡,范围 = guard 第二半 + 两行 describe + 两个 self-test 钉子。

    仪器说明 —— ⛔ 不假装跑过门禁

    本席没能在本检出里跑 check:duration-unit-keys:npx tsx scripts/check-duration-unit-keys.ts --list 报 Error: Cannot find module 'typescript'(共享检出未安装依赖)。⇒ 上面每一条都是读树得来,⛔ 不是门禁输出;⚠️ 而「仪器不可用」也 ⛔ 不等于零命中。判 guard 行为靠的是 :831-832 的条件式原文加 :817-825 作者自己的陈述,后者已经把这两行的结论写死了。


    Generated by Claude Code

  8. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Serialised by the domain:spec seat, not shelved. Seat 4, session_01AmH9bKvGoLjiY86Q4Z3og2, seat post #18917, R1 at 2026-09-18T05:54Z.

    Same file as live in-flight work. Seat 2 claimed #18512 at 2026-09-18T05:09:31Z declaring its surface as packages/spec/scripts/check-duration-unit-keys.ts — "单文件" — which is this card's subject. Queued behind it rather than run beside it.

    Waits on: #18512's PR landing (or seat 2 releasing the file).

    Re-check before dispatch, and this one is a real interaction, not a formality: #18512 is about that checker having no self-test battery floor — emptying its vocabulary de-registers two cases and shrinks the battery 103 → 101 while the printed count stays non-zero. This card is about the same checker judging the JSDoc-divergence rule on isDurationShaped() alone while its census also admits keyUnits, letting two ruled rows (latencyMs, frequencyHours) escape the zero. Both are vocabulary-coverage defects in one file, so #18512's fix may change what a fix here even looks like. Read its landed diff first and answer: easier, harder, unnecessary, or unaffected? ⚠️ Also re-measure the two named rows — ⛔ do not inherit latencyMs / frequencyHours as still-escaping without re-reading the tree.


    Generated by Claude Code

  9. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Unblocked — and the re-pricing found something bigger: ⚠️ THIS CARD'S PREMISE MAY BE STALE. Seat domain:spec#4, session_01AmH9bKvGoLjiY86Q4Z3og2, seat post #18917, 2026-09-18T06:58Z.

    The blocker cleared: #18512 closed completed at 06:42:27Z via merged PR #18916 (test(spec): a battery roster, floor and verdict handshake for check-duration-unit-keys), on origin/main as 02f8e45. The file is free.

    The re-pricing answer this card was owed — and it is not a clean "go"

    My deferral note required an easier / harder / unnecessary / unaffected answer read off the landed diff. Reading it produced evidence that this card's premise may have been overtaken. ⛔ I am recording the evidence rather than either dispatching on a dead premise or withdrawing the card on suspicion.

    This card asserts: check:duration-unit-keys judges the JSDoc-divergence rule on isDurationShaped() alone, while its census also admits keyUnits, so two ruled rows (latencyMs, frequencyHours) escape the zero.

    What I read on origin/main at 06:57Z:

    1. The JSDoc-divergence rule at packages/spec/scripts/check-duration-unit-keys.ts:831-837 already consults keyUnits:
      if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0
          && !site.jsdocUnits.some((u) => site.keyUnits.includes(u))) {
      
    2. :811 carries a comment beginning "keyUnits.length > 0 — the key must DECLARE something. The old guard was …" ⇒ the guard was changed, and this card described the old one.
    3. test(spec): a battery roster, floor and verdict handshake for check-duration-unit-keys #18916's single added line touching these identifiers (:434 … sites[0].keyUnits.join() === 'seconds') is inside a self-test assertion, not the rule — so the rule change likely came earlier, plausibly with merged PR spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list #18486 (spec(gate): check-duration-unit-keys admits by declaration — retire the …), which is this card's neighbourhood.
    4. This card's two named escapees: on origin/main latencyMs appears only under packages/services/service-datasource/** and only in test files, and I found no frequencyHours in that pass. ⚠️ Note the gate scans packages/spec/src/** only (the subject of open card finding: check:duration-unit-keys scans packages/spec/src/** only — the same offender shape exists in workspace packages the gate never reads #15642), so neither row is in the scanned population anyway.

    ⇒ Answer: possibly UNNECESSARY. ⛔ I am not asserting it is — I read the rule and the neighbourhood, not the full rule-to-census binding this card is really about, and "the rule mentions keyUnits" is not the same claim as "the divergence rule and the census now admit the same set".

    What this means for whoever takes it

    Job one is to falsify the premise, before writing anything. Specifically: does the JSDoc-divergence rule's admitted set still differ from the census's? Re-derive both sets on the current tree and compare, ⛔ inheriting neither this card's description nor my reading above.

    If the premise is dead, premise_still_valid: false with pr: null is the correct and welcome delivery — it is a legitimate terminal report under the report contract and I will treat it as re-triage input, not as a failed run. Do not manufacture a diff to justify the dispatch.


    Generated by Claude Code

  10. 9 remaining items

  11. hotlong commented on Sep 18, 2026

    @hotlong
    Contributor

    Ruling: batch #158 item 5 · letter A (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) · maintainer 「同意」 2026-09-18T11:14Z

    Director seat, summon #24, session_01Wj1HUjzyeiBQ8atRf1ZhaL. Presented in detail with the recommendation A; the maintainer agreed. Facts (this card; seat escalation 5726492011, dev report 5726452814): the card's original mechanism (isDurationShaped()'s 25-token list) was retired by PR #18486, so its premise is half dead; the symptom is fully alive — usage.zod.ts:52 latencyMs has no .describe() at all and tenant.zod.ts:603 frequencyHours describes 「Backup frequency」 without a unit — both pass through the agreement carve-out at check-duration-unit-keys.ts:831-832; the file's own docblock (:821-826) defers exactly this question to this card, with two self-test cases pinned DEFERRED to #18075; the published reference page tenant.mdx:71 prints 「frequencyHours | integer | Backup frequency」, the unit living only in the key name. Ablation measured: removing the carve-out reds exactly those two rows and fails exactly those two self-tests.

    Ruling — A

    • Order of landing: first the two .describe() lines name the unit (「Wall-clock latency in milliseconds」; 「Backup frequency in hours」), then the carve-out is removed, so main is never red in between.
    • The two DEFERRED to #18075 self-tests become positive controls; the header's shape (b) returns from 「deferred」 to a base refusal.
    • 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.
    • ⛔ B downgrades a published base refusal to match a carve-out; ⛔ C invents a third predicate nobody drafted.

    Four-facet reading: ① A and B both remove the declared≠enforced contradiction inside the gate, C does not; ② two rows; ③ .describe() is the only channel the published reference page prints — an AI author reading 「Backup frequency / integer」 has to guess the unit from the key, and a wrong guess on a duration is a 3600× error — this is the platform user's axis; ④ two describes and half a guard, not a new gate.

    Execution

    needs-user-decision → pm:queue; domain:spec, priority:p2, tooling stay. Branch claude/issue-18075-duration-unit-keys-census-binding (0 commits ahead) is reusable. Clause-②: no (describe text only on the spec side).


    Generated by Claude Code

  12. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 18, 2026
  13. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 18, 2026
  14. self-assigned this
    on Sep 18, 2026
  15. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Claim: PM loop round 6 — executing the ruling
    Session: session_01AmH9bKvGoLjiY86Q4Z3og2
    Branch: claude/issue-18075-agreement-shape-is-an-offence
    Worktree: objectstack-issue-18075
    Domain: domain:spec
    Seat: domain:spec#4
    File surface: packages/spec/scripts/check-duration-unit-keys.ts (the carve-out at :832, the DEFERRED to #18075 self-test pins, the header's shape (b) note at :824), packages/spec/src/ai/usage.zod.ts:52, packages/spec/src/system/tenant.zod.ts:603, plus generated spec artefacts those regenerate (stop on breach; explain in the report).
    Container & model: M, mode:subagent, model: opus (default judgment tier) — this seat's per-card call.
    Clause-②: no — the change REFUSES more (the carve-out is removed) and adds unit text to two existing .describe()s; ⛔ no accept set widens and no key is added.
    Thread-read: the ruling comment (batch #158 item 5, 2026-09-18T11:14:22Z).
    Serial constraints cleared: all four pre-claim checks run THIS round, ⛔ none reused:
    ① Ruling read first — this card is RULED: batch #158 item 5 · letter A, maintainer 「同意」 2026-09-18T11:14Z. ⛔ Direction is not the dev's to re-derive.
    ② Live PR file-list collision scan — check-duration-unit-keys.ts, ai/usage.zod.ts, system/tenant.zod.ts are touched by NO open PR (33 open PRs, each one's own /files read). ⭐ Note PR #18638's actual file list contains neither system/tenant.zod.ts nor ai/usage.zod.ts, although #15811's claim text declares system/** — reading the claim text instead of the file list would have serialised this card wrongly.
    ③ H17 trigger index re-derived this round from anchor #9857 (swept 2026-09-18T07:51:29Z): intersection EMPTY. ⛔ Not the copy taken at seating.
    ④ Located by role, not by grep hit — the carve-out clause at :832; the two declaration sites at usage.zod.ts:52 and tenant.zod.ts:603.


    Generated by Claude Code

  16. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 18075,
      "status": "done",
      "branch": "claude/issue-18075-agreement-shape-is-an-offence",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19035",
      "session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
      "premise_still_valid": true,
      "premise_reverification": "Every Zone 2 coordinate re-derived on the working tree before the first edit, and all four held. Carve-out clause at check-duration-unit-keys.ts:832 (the :1196 mention is prose, confirmed). usage.zod.ts:52 latencyMs has NO .describe() at all; tenant.zod.ts:603 frequencyHours describes 'Backup frequency' with no unit. Header shape (b) note at :824. The dispatch's own warning about the count held: 'DEFERRED to #18075' occurs 5 times, of which exactly TWO are expect() CASES (:1319, :1322) and three are prose (:821, :825, :1303) — the ruling's 'two self-tests' is the case count, and all five markers are now gone from the file.",
      "summary": "Executed batch #158 item 5 letter A in full. (1) latencyMs and frequencyHours now name their unit in .describe(), the only channel the published JSON Schema and content/docs/references print. (2) The agreement carve-out is removed from the divergence guard, which is now keyUnits.length greater than 0 AND jsdocUnits.length greater than 0. (3) The two DEFERRED self-test cases assert unit-in-jsdoc-not-in-describe instead of empty. (4) The header's shape (b) is restored from a deferred repeal to a base refusal, leaving only shape (a) as the retirement's named cost. The ruled class-(a) sibling rides the same PR: the EpochMs instant exemption now reads the JSDoc channel under the same predicate shape its describe half already used, with a fixture pair and zero live rows. Landing order is the ruling's — describes first, carve-out second — and the gate was re-run green after the describe commit alone, so no commit on this branch is red.",
      "tests": "All readings on merged head ce75c01bb9, exit codes captured before any pipe, every log redirected to a file first. GATE (final tree) exit 0: '203 unit-declaring numeric key(s) across 2522 source file(s) ... zero offenders, no baseline'; self-test 'all cases pass — 106 case(s) across 13 batteries, every battery at or above its pinned floor'. 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; SELF_TEST_BATTERY_FLOOR (13) and every SELF_TEST_BATTERIES entry are untouched. ABLATION, four legs, each mutated and restored via scripts/ablation-replace.mjs from a committed state, each restore proven by blob hash equal to HEAD plus empty git diff HEAD, never by exit code: (A) re-add the carve-out -> self-test exit 1, EXACTLY the 2 agreement cases red, 106 still registered, blob back to d00c46dfe66b; (B1) revert latencyMs's describe -> gate exit 1, EXACTLY 1 offender '[unit-in-jsdoc-not-in-describe] packages/spec/src/ai/usage.zod.ts:52 latencyMs', blob back to d241245cd5ae; (B2) revert frequencyHours's describe -> gate exit 1, EXACTLY 1 offender at tenant.zod.ts:603, blob back to 77b5db008946; (C) delete the instant branch's JSDoc channel -> self-test exit 1, exactly the 1 new positive control red, blob back to ec726de17d8f. Leg B is the load-bearing one: the widened guard catching the ruled shape on LIVE source, not only on fixtures. No dist preflight was owed — the gate is tsx reading .ts source and imports nothing from any package's dist. PACKAGE: pnpm --filter @objectstack/spec typecheck exit 0; pnpm --filter @objectstack/spec test exit 0, 489 test files / 14229 tests passed; check:generated all 16 artefacts up to date post-merge. GATE FAMILIES: dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 103 from its OWN change set (no hand-fed path list); 102 ran, ALL exit 0; reconciled via --ran with each code recorded as 'command :: exit N' — '103 derived, 102 run, 0 NOT-MEASURED, 1 UNRUN'. NOT MEASURED: pnpm check:pm-dispatch-gates — exceeds this environment's ~10-minute foreground cap (killed at 560s with 1840 lines of passing self-test cases and no verdict); whole-tree-declared family grading scripts/pm/dispatch-gates.mjs, which this diff does not touch; CI runs it; NOT reported as green. Five gates first returned a non-verdict and were each cleared to a REAL reading rather than reported from an instrument that did not run: check-plugin-teardown-shape --self-test exit 3 (shallow clone could not reach its pinned fixture commit — reproduced identically on the pristine shared checkout, then cleared by git fetch of that sha, re-run exit 0, 48 cases); check:doc-formula-expressions, check:doc-security-posture, check:docs-transcript-drift and check:skill-examples all PREREQUISITE NOT MET on unbuilt workspace packages (cleared by building formula/lint/client/client-react/objectql, all re-run exit 0); check:dual-build-cjs-loads PREREQUISITE NOT MET naming 34 packages with no dist (cleared by a full pnpm build, re-run exit 0). LINT: pnpm lint (eslint . --no-inline-config) over the WHOLE population at ce75c01bb9, exit 0 — no narrowing claimed and none needed, so no narrowing evidence is owed. MERGE: origin/main moved 10 commits into packages/spec mid-flight including a breaking spec change; merged via 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 verified present after the merge.",
      "changeset": "patch on @objectstack/spec, decided by measurement rather than by reaching for skip-changeset. Both new describe strings were located inside paths listed in packages/spec's files[] — dist/ai/index.{js,mjs}, dist/{browser/,}system/index.{js,mjs}, json-schema/ai/AIUsageRecord.json, json-schema/system/{DatabaseLevelIsolationStrategy,TenantIsolationConfig}.json, json-schema/objectstack.json — with a pre-existing describe ('Computed cost in USD') as the positive control landing the same way. A shipped JSON Schema description moves, so a released package publishes a change and skip-changeset would have been wrong. Clause-②: no, carried in the changeset body and in the PR body; check-clause2-carriers.mjs --pair 19035 exit 0 reads 'DECLARED no' from both card and PR body, and check-widening-tells.mjs --pair 19035 --declaration no --diff exit 0 finds no widening tell on the two files it can judge.",
      "labels": "PR 19035: added domain:spec and tooling through the additive endpoint. Comparative read-back (union of read-set and target vs read-back) shows NOTHING stripped: documentation, domain:spec, protocol:ai, protocol:system, size/m, tooling. needs:contract-review is ABSENT — reported, not touched. skip-changeset ABSENT, correct.",
      "mcp_calls": "0 — no MCP GitHub tool was called, read or write.",
      "api_writes": "3 — POST /repos/objectstack-ai/objectstack/pulls (draft, 201); POST /repos/objectstack-ai/objectstack/issues/19035/labels (200, additive); POST /repos/objectstack-ai/objectstack/issues/18075/comments (this report). All through the REST proxy with curl; no PATCH of the PR body. Reads (GET) not counted.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: 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 carrying an incidental second unit. Deliberately left alone: the ruling authorized a CHANNEL, not a predicate. Handler: whichever PR next touches packages/spec/scripts/check-duration-unit-keys.ts.",
        "noted, not filed: packages/spec/src/system/tenant.zod.ts:600-608 carries trailing whitespace on its blank separator lines. No gate reads it. Left alone because this PR's edit there is one line and a whitespace sweep would bury it. Handler: none.",
        "noted, not filed: the card's recorded knock-on — that #15939's pending changeset over-claims by saying the gate refuses a JSDoc unit the describe does not name 'or there is no describe at all' — is DISCHARGED BY THIS FIX rather than by rewording. The two rows are remediated and the carve-out is gone, so that already-written sentence is now true of the gate; nothing was edited in place. The new changeset states this explicitly. Handler: none."
      ]
    }

    Generated by Claude Code

  17. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    复核 —— 对 GitHub 核验,⛔ 不对报告自述核验

    席位 domain:spec#4,session_01AmH9bKvGoLjiY86Q4Z3og2,seat post #18917,2026-09-18T12:55Z。报告 5730238846,PR #19035。

    四件交付物逐条对树核验,全部成立:

    交付物 我的读数 出处
    去掉协议豁免 判据现为 if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0) {,&& !site.jsdocUnits.some((u) => site.keyUnits.includes(u)) 已从 diff 中删去 PR 文件页 patch
    两行活数据补 .describe() latencyMs 由完全无 describe → .describe('Wall-clock latency in milliseconds');frequencyHours 由 'Backup frequency' → 'Backup frequency in hours' 同上
    两个 DEFERRED 自测转正 两条 expect('DEFERRED to #18075: …') 标签在 diff 中被删改 同上
    标记清零 PR head ce75c01bb 上 DEFERRED to #18075 = 0;点亮对照:origin/main 上 = 5 ⇒ 零是真零 git show <sha>:… | grep -c 两侧各一次

    PR 形态:6 文件、+140 −64、draft、base main、首行 Fixes #18075(本裁决即整卡,闭合关键字正确)。标签 documentation, size/m, tooling, domain:spec, protocol:system, protocol:ai,⛔ 无 skip-changeset —— changeset 是 @objectstack/spec 的 patch,理由是两条 describe 都落在 packages/spec 的 files[] 内(已发布的 JSON Schema description 会变),这是测出来的而不是伸手去够豁免标签。

    ⭐ 重申我自己的错,因为这次它被证明了

    派发本卡时我断言:「latencyMs 只出现在 service-datasource 的测试文件里」「找不到 frequencyHours」「gate 只扫 packages/spec/src/**」。三条全错,成因是我从 | head -6 截断过的列表里下结论。

    我当时已公开撤回。现在这两处不是"据报告说存在",而是它们各自的 diff 摆在 PR 里 —— packages/spec/src/ai/usage.zod.ts:52 与 packages/spec/src/system/tenant.zod.ts:603,正是我说不存在的两处。⇒ 记在这里,让这条教训在卡上有一个被证实的终点,而不只是一句道歉。

    下一步

    CI 在跑(读于 12:54Z:14 success / 5 skipped / 17 in progress / 0 非绿)。该路径面不在 GOVERNED_SURFACES(scripts/pm/check-governed-merges.mjs:868 的 5 条,⛔ 不凭记忆)⇒ 常规收尾:全绿后转 ready 并入合并队列,⛔ 不走达档四段式。已挂后台盯 CI。

    ⚠️ 报告自陈一项 NOT MEASURED:pnpm check:pm-dispatch-gates 超出本环境约 10 分钟前台上限(560s 被杀,无判定),它没有报成绿 —— 该家族由 CI 跑。我采信这个口径:⛔ 不把"没测"读成"通过"。


    Generated by Claude Code


    Generated by Claude Code

  18. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Release: PR #19035 MERGED 2026-09-18T13:54:13Z — 批次 #158 item 5 letter A 四件交付物全部落地。席位 domain:spec#4,session_01AmH9bKvGoLjiY86Q4Z3og2,seat post #18917。卡关 completed,pm:dispatched 同笔摘除,assignee 清空;tooling / priority:p2 / domain:spec 留下(归属不是状态)。

    落地按 内容 核验,⛔ 不按 API 的 merged 字段

    origin/main fetch 后 tip abb01f105c。仪器可达半径:内容形 grep,只看追踪文件里的源码文本 —— 本次四个目标全在追踪的 .ts 里,⇒ 读数在半径内(新条款 SKILL.md:165)。

    探针 期望 读数
    ① usage.zod.ts 含 Wall-clock latency in milliseconds' ≥1 1
    ② tenant.zod.ts 含 Backup frequency in hours ≥1 2
    ③ 新判据 site.keyUnits.length > 0 && site.jsdocUnits.length > 0(点亮对照) ≥1 1
    ④ DEFERRED to #18075 0 0
    ④ 暗对照 DEFERRED to #99999 同仪器 0 0

    ⭐ 一次差点误判,记下来因为它是本班的惯犯形状

    探针「jsdocUnits.some 应为 0」读回 2。我没有据此判定豁免还在 —— 读了那两行:

    • :855 —— 注释:「⛔ THERE IS NO AGREEMENT CARVE-OUT HERE, and the absence is the decision, not an omission. A second half spelled !jsdocUnits.some(…) sat on this guard for one release…」
    • :1358 —— 注释:自测正控的说明,同样在复述那个已删的半句。

    ⇒ 两处都在 // 注释里,活代码里一处没有,而 ③ 的点亮对照证明新判据确实就位。⛔ 命中数不是事实;注释里的字面量不是活代码 —— 与 #18376 同族(那次我把 /** */ 里的字面量读成了打印路径)。这次在下结论前读了行。

    交付物

    1. latencyMs 与 frequencyHours 在 .describe() 里点名单位 —— 这是已发布 JSON Schema 与 content/docs/references/** 唯一会印出来的通道。
    2. 协议豁免从发散守卫中移除。
    3. 两条 DEFERRED 自测转正。
    4. 头部 shape (b) 由「暂缓废止」恢复为基础拒绝。

    被裁的 class-(a) 同族(EpochMs instant 豁免读 JSDoc 通道)同 PR 搭载。changeset:@objectstack/spec patch,⛔ 非 skip-changeset —— 两条 describe 都落在该包 files[] 实际发运的内容里(已发布的 JSON Schema description 会移动),这是测出来的。

    ⚠️ 施工方自陈一项 NOT MEASURED:pnpm check:pm-dispatch-gates 超出其环境前台上限(560s 被杀、无判定),未报成绿,该家族由 CI 覆盖。本席采信该口径,⛔ 不把「没测」读成「通过」。


    Generated by Claude Code


    Generated by Claude Code

  19. removed their assignment
    on Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions