spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list - #18486
Conversation
…e name-shape token list The census admitted a numeric key three ways: a unit in its describe prose, a unit token in its key name, or a match against a 25-token list of words that "read like a duration" (`timeout`, `ttl`, `window`, `stale`, `age`, ...). The third one is retired. Admission is now by DECLARATION: a closed duration or instant type in the zod chain, or a unit token in the key name, plus prose so that prose can still be judged against the name. - the token-set constant and the predicate that read it are deleted, and a self-test case reads this source and asserts both identifiers are absent, with a positive control, so the retirement cannot rot back in - the closed duration vocabulary is read as an admission channel, exactly the way the epoch instant root already was: the type states the unit at the authoring site and in the published JSON schema, so it waives the key-name requirement and nothing else. Both contradiction directions stay refusable - a `dimensionless` schema marker joins `externalVocabulary` in the same reader, for a count whose prose names a time unit belonging to something else in the sentence - the census drops from 208 rows to 185. Every one of the 23 rows that left is unit-free through every channel this file reads -- name, describe, JSDoc and type -- measured one by one, against a positive control on the 185 that stayed. Nothing entered The cost is recorded rather than hidden: the JSDoc-divergence class's original positive controls (`timeout`, `window`, `interval` with a unit in the JSDoc alone) all rested on the retired list, so those keys are no longer admitted and no longer refused. The surviving half is the one that rests on a declaration -- a key whose name carries a unit its JSDoc contradicts -- and the route back for the others is a `Duration*` type. All three shapes are pinned as explicit cost cases so a future re-admission goes red and says what it is re-opening. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Required changes for PASS (documentation and pins only; ⛔ no behaviour change,
Generated by Claude Code |
… both directions The name-list retirement repealed a second base refusal that the header, the judge() comment and the PR body did not name: a key whose stem was in the retired list AND whose name carries a unit token, whose JSDoc names the SAME unit and whose describe names none (timeoutMs + JSDoc "in milliseconds" + describe 'Maximum execution time'; intervalSeconds + JSDoc "in seconds" + no describe). The base guard was the name-shape predicate, whose reach was the STEM, so both satisfied it; the head lets them through via the agreement carve-out on the divergence branch. Documentation and pins only -- no behaviour change, census stays 185/0. - header: name both repealed shapes and state that the second is DEFERRED to #18075, not decided correct. The retired predicate is described rather than named because this file's own retirement pin asserts that identifier is gone from the source. - judge() comment: replace the argument that refusing agreement "would manufacture offenders" -- which adjudicated open card #18075 -- with a deferral naming it. - two self-test cases labelled DEFERRED to #18075 pin both fixtures as '' today, so deleting the carve-out goes red. It reded 0 cases before. - de-vacuify the DurationMs type-channel case: rulesOf() returns '' both when a key is admitted and when it is outside the population, so assert durationType and typeUnits the way the DurationSeconds case already does. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing provenance — PR #18486, card #18123, head
|
| reading | value |
|---|---|
| reviewer tier by transcript | 95 / 95 claude-fable-5-1; control an os-dev round in the same container 297 / 297 claude-opus-5 |
| record, adopted verbatim | comment 5701703741 |
| independence pair | Implemented-by: claude/issue-18123-duration-census-admits-by-declaration · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6 |
落地前检三条
| check | reading | |
|---|---|---|
| ① | at-tier PASS naming this head | record 5701703741, Head-sha: 750aa5b0954a31434d6c6df428478fad8b6035ed |
| ② | --pair 18486, re-read after the carrier strip |
exit 0 |
| ③ | every check green, latest run per NAME | read after the ready flip, below |
check-governed-merges.mjs --pr 18486 → 0 of 1 path hits the register ⇒ NOT governed, derived on the FINAL file list.
⭐ Why this PASS is worth more than the previous FAIL was worth
The FAIL said the carve-out was unpinned in both directions — deleting it reddened 0. This review re-ran that mutation on both heads and got the discriminating pair:
delete the agreement carve-out
at the prior head → self-test exit 0, 101 green, 0 red
at THIS head → self-test exit 1, 101 green + 2 red = 103 registered
and the 2 red are exactly the two DEFERRED cases
gate exit 1 on usage.zod.ts:52 latencyMs
and tenant.zod.ts:603 frequencyHours
⇒ the pin now bites, and 「103 registered either way」 is what proves nothing de-registered to fake it. Deleting the whole branch reds 3 (both divergence positives plus a mirror) while the DEFERRED pair stay green — so the two halves are separable, which is what a deferral has to mean.
Behaviour is byte-identical to the reviewed head: 37 fixtures, 0 differences; live tree 0 admission and 0 verdict differences over 1036 sites; --list and gate output cmp exit 0. ⇒ this remediation is documentation and pins, measured, ⛔ not asserted.
The old vacuity is gone and the proof is symmetric: the retired assertion read true both on a site outside the population and under the vocabulary ablation; the new one reads false on both and true only on the admitted DurationMs site.
⛔ Two things this landing does NOT settle, stated rather than buried
The decision-box question on acceptance item (e) is still open, and this PR lands anyway. I checked that reasoning rather than taking it: the shipped reading (A) is the narrower accept set of the two and leaves every standing ruling intact. ⇒ if the maintainer later answers B, that is a follow-up change on a landed gate, ⛔ not a retraction of this landing. It remains item 3 in the decision box.
#18075 is held, and the hold lifts here. Its comment 5700898658 carries 「⛔ do not dispatch while PR #18486 is open」, because this PR's predicate was its subject. Once this merges, that hold is spent and its step 2 reads as restated: flip the DEFERRED pins to POSITIVE after the two describe-only remediations. ⛔ Not before — refusing the agreement shape today reds latencyMs and frequencyHours on main, which is the sequencing ruling A rejected as option B.
Residue filed BEFORE this enqueue
- check-duration-unit-keys has no self-test battery floor: emptying its vocabulary DE-REGISTERS two cases, shrinking the battery 103 to 101 while the printed count stays non-zero #18512 — this gate has no self-test battery floor: emptying the vocabulary de-registers two cases (103 → 101 at this head, 101 → 99 at the prior one) while the printed count stays non-zero. Carries the two residual vacuous cases (F2) in the same card, same root cause.
- F7 (
dimensionlessdocs-renderer half) — the record calls it 「still unfiled」; it is not: this seat filed it as thedimensionlessmarker lands with a gate reader but no docs-generator half — #14478 ruling B put both in the mechanism #18500 at 17:10Z. ⛔ Not re-filed.⚠️ Recorded so the next reader does not file a third copy. - F5, F6 and F9 are pre-existing and carried on spec: the genuine duration rows adopt a declaration —
DurationMs/DurationSecondsor a unit-suffixed name (census #18078 classes A-true ×6, B ×2, D ×≥2) — step ③ of ruling A on #18115 #18124, as the record and the PR both say. ⛔ Nothing new filed for them.
Check Changeset and a ready flip starts Governed Surface Queue Guard.
Generated by Claude Code
|
| hypothesis | reading that rules it out |
|---|---|
| credential dead or repo-scoped out | GET /rate_limit → core 14753 of 15000; every read-back in this window returned 200 |
| quota exhausted | same reading; ⛔ a 503 is not a 403 and not a rate-limit body |
| an unverified route shape | the same route returned HTTP 200 on PR #18501 at 17:17:41Z, read back draft=False. ⇒ it works in principle; this is a service failure, ⛔ not a shape this seat never verified |
⛔ The two backup channels, and why neither is taken
- MCP
update_pull_request— the documented fallback, and ⛔ refused here on purpose.origin/main's.claude/settings.jsondenies it; this container's copy is stale and still permits it. The standing hedge for that gap is 0 MCP GitHub writes, seat and devs. ⇒ reaching for it now would be doing exactly the thing the hedge exists to prevent, with the excuse that the正门 is down. - Bare
PATCH /pulls/18486with{"draft": false}— ⛔ not a channel. Measured 2026-09-11: returns 200 and changes nothing; the read-back still says draft. A 200 that does nothing is worse than a 503 that does nothing.
State, so nobody has to re-derive it
The landing pre-check is complete and green — ① at-tier PASS naming this head (5701703741), ② --pair 18486 exit 0 after the carrier strip, and the PR is NOT governed (0 of 1 path). Both carriers are stripped. ⇒ the only thing between this PR and the queue is a platform route.
⛔ Ready is not flipped and auto-merge is not attached. A retry is armed and will flip it the moment the route answers; ③ is then read after that flip, before any enqueue.
Generated by Claude Code
Part of #18123 — step ② of ruling A on #18115, executing batch #139 item 3 (director seat, maintainer 「同意」 2026-09-16T01:57Z): 「名字表退休」.
5700807570) returned FAIL on an undeclared repeal — documentation and pins only, ⛔ no behaviour change. This revision carries its five required changes; the census is unmoved at 185 / 0 offenders.What changed
The census admitted a numeric key three ways: a unit in its
.describe()prose, a unit token in its key name, or a match against a 25-token list of words that read like a duration (timeout,ttl,interval,window,stale,age, …). The third one is retired.DurationMs/DurationSeconds, landed by step ① in spec: closed duration typesDurationMs/DurationSecondsbesideEpochMs— step ① of ruling A on #18115 (declared shape for the unit-in-key census) #18122) is read as an admission channel, exactly the way theEpochMsinstant root already was: the chain is walked to its root, not peeked at. The type states the unit at the authoring site and in the published JSON schema, so it waives the key-NAME requirement and nothing else — both contradiction directions stay refusable asduration-unit-contradicts-schema.dimensionlessschema marker joinsexternalVocabularyin the same reader, under the same literal-only validation (an empty or computed value declares nothing and exempts nothing).--listno longer surfaces name-matched rows; the summary line reports declared duration types beside declared instants.There is no committed baseline to shrink. Measured, not assumed: this gate has no ledger file — its own header records 「不考虑存量」 and the verdict line prints
zero offenders, no baseline. The card's "--listbaseline updated in the same PR" has no artefact to point at. The census counts below are the reading that stands in for it.Before / after — every number measured on this tree
85c6d76ec4)77d8a5337f)--list)EpochMsinstantsexternalVocabularymirrorsdimensionlessnumbers--self-testcasesCensus diff, both directions: 23 removed, 0 added.
The 23 rows that left, named
Every one is unit-free through every channel this file reads — key name, describe, JSDoc, and value-chain type — checked one at a time, not in aggregate:
census AND keyUnits = 0 AND proseUnits = 0 AND jsdocUnits = 0); the 17 is the dimensionless SUBSET inside it, and the other 6 are the genuine durations step ③ (#18124) converts. This PR's set was derived independently, from a before/after census diff, and it matches #18078's row for row.The 6 genuine durations, still unconverted and now out of the census:
⛔ No key is renamed or retyped here — that is step ③'s work, and the card fences it.
.describe()alone putdata/driver.zod.ts:414 idleandsystem/metrics.zod.ts:224 ageBucketsamong the genuine durations, giving 15 + 8. Both have NO describe, so the describe-only read had nothing to go on; #18078 read them in source and classified them as counts (pool connections; "Number of age buckets"). Its split is the correct one and is what appears above. The mechanical 23 — which is what this change actually keys on — was identical either way.Judged in BOTH directions — this gate now judges FEWER keys
That is an accept-set widening in the gate's own terms, so each direction is measured rather than argued.
Does any key judged today fall out? Zero — and the zero is admissible: the gate reports
0 offenderson both trees, so no active refusal was lost on any row; and each of the 23 was re-read through all four channels with a positive control on the 185 rows that stayed (name 169, prose 160, JSDoc 75, instant 5, mirror 11 — a non-empty reading from the same probe).The adversarial input just outside the predicate's boundary. The failure shape this seat was burned by on PR #18427 is a predicate that reads only the opening of a value, so a live key goes silent by chaining one more method on. Pinned as self-test cases:
DurationMs.or(z.string())→ still resolves to the duration root, still refused for its contradicting prose. The chain is walked.z.number().or(z.string())under attlMsname → stillname-unit-contradicts-prose.z.union([DurationMs, z.string()])→ az.unionroot, outside the population. Pinned as a known boundary, not discovered later: it is the pre-existing behaviour of the instant root too, unchanged by this PR.What the retirement COST, stated rather than hidden. The
unit-in-jsdoc-not-in-describeclass (#15939, batch #65) was guarded by the retired name-shape predicate, whose reach was the key's stem — so it admitted unit-suffixed names too, and two shapes stop being refused here, not one.(a) A bare list-shaped key that declares nothing —
timeout,window,intervalwith the unit in a JSDoc alone. Those three were the class's original positive controls and all three stop being refused. They are pinned as explicitCOST of 退休self-test cases, plus a "route back" case showing the same key typedDurationMsis admitted and refused again — so a future change that re-admits them goes red and says what it is re-opening.(b) a key whose stem was in the retired list AND whose name carries a unit token, whose JSDoc names the SAME unit and whose describe names none (
timeoutMs+ JSDoc "in milliseconds" + describe 'Maximum execution time';intervalSeconds+ JSDoc "in seconds" + no describe) was refused by the base gate asunit-in-jsdoc-not-in-describeand is not refused here. It is not refused because refusing it redslatencyMs/frequencyHoursonmain(#18075's ordering constraint: remediation before widening), ⛔ not because agreement is not an offence — that is #18075's open question.Shape (b) is a declared deferral, not a decision, and it is now pinned as one: two self-test cases labelled
DEFERRED to #18075hold both fixtures at''today, so deleting the agreement carve-out goes red. Before this round, deleting it reded 0 cases — the repeal was invisible in both directions. What survives as a live refusal is the half resting on a declaration the JSDoc contradicts: a key whose NAME carries a unit its JSDoc disagrees with.#18075 is not addressed here, and is not pre-empted. It reports that this same branch is gated too narrowly and that two rows of the ruled shape escape (
latencyMs,frequencyHours— measured). Both have a JSDoc unit that agrees with their key name, so the agreement carve-out fires on neither: its ordering constraint (remediation before widening) is untouched and that card remains open and still meaningful. ⛔ Nothing here rules on whether agreement is an offence. An earlierjudge()comment in this PR argued that it is not — which adjudicated that open card while this body claimed it was not pre-empted — and it has been replaced with a deferral naming #18075.Ablation — the new cases are red without the change
Mutation proven on disk before any result was read (anchor grep before/after); restore proven by blob hash and an empty
git diff HEAD, never by an exit code; both legs under atrap ... EXIT INT TERMwith absolute paths.&& !jsdocUnits.some(...))DEFERRED to #18075cases. Before this round: 0timeout)RETIRED:cases and the undeclared-JSDoc casefor (const root of DURATION_ROOTS.keys())— with the map literally empty the loop body never runs, so it cannot go red; it silently registers zero cases instead. The mutation differed from its description. Re-measured on this head the leg is still 6, but the sixth is a different case:admitted by TYPE: a DurationMs key needs no unit in its name, which was vacuous before this round and now assertsdurationType === 'DurationMs'. On the reviewed head the true count was 5.Restores proven on every leg by blob hash and an empty
git diff HEAD, never by an exit code; mutations proven on disk by anchor grep before/after with lit and dark controls; every leg under atrap ... EXIT INT TERMwith absolute paths. Carve-out leg: HEAD blob556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835, mutated bloba17c4174f20555fa42ef759f46de7f2c46f473b0, restored blob556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835== HEAD,git diff HEADexit 0, restored self-test exit 0 / 103 cases / 0 red.A third proof arrived unplanned: the source-scan case failed for real during development (15:19:08Z, exit 1, one case) on a leftover mention of the retired identifier in a comment this PR itself added. It is not a case that cannot fail.
⛔ One ablation attempt was discarded rather than reported: an earlier spelling injected an undefined identifier, so the run exited 1 by crashing with zero red cases. A crash is not a red gate; it was re-run with an expression that compiles, and the table above is that second run.
Verification
tsx scripts/check-duration-unit-keys.ts --self-test→ exit 0, 103 casestsx scripts/check-duration-unit-keys.ts→ exit 0,185 unit-declaring numeric key(s) ... zero offenders, no baselinenode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(56 commands) — all 56 run, all exit 0$?, never through a pipeChangeset:
skip-changeset, measuredpackages/specis published, but itsfiles[]isdist,json-schema,liveness,prompts,llms.txt,README.md,src/**/*.zod.ts,CHANGELOG.md,api-surface,spec-changes.json—scripts/is not in it. The diff is one file underpackages/spec/scripts/, and no published path imports it (0 importers; control: 50 files in the same directory import a sibling./lib/module). Positive control for the symbol grep:DurationMsreturns 14 hits over the same published pathspec, so the zeros are readings rather than a broken probe.Acceptance notes
ttlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478 ruling B:ttl+ "in seconds" is an offence) along with the eleven landed self-test assertions that pin it (counted on the base blob; one of them pins four offences of that rule at once), and makesexternalVocabularyinert — all 11 declared mirrors are prose-admitted rows with no unit in their names, so they would leave the census and the marker would stop being read for anything, against [#14478 stack 1/6] declare the two exemption classes ON THE SCHEMA — a sharedEpochMsfor the 6 epoch instants and a.meta({ externalVocabulary })marker on the 13 external-standard keys, honoured bycheck:duration-unit-keysand printed by the docs generator #15676 ruling B's 「exemptions stay visible and counted」. Neither consequence is mentioned anywhere in the ruling chain, and the ruling's own arithmetic (「17 个无单位数自然出列」) describes 23 rows leaving, not 34. This PR therefore keeps prose as an admission channel and retires only the name shape — the narrower accept set of the two readings. Flipping to the literal reading is one line indeclaresUnit. ⛔ Not decided here.packages/spec/scripts/check-generated.ts:248still describes this gate as auditing "a duration-shapedz.number()key" — stale vocabulary now that the name shape is retired. Prose only, no behaviour; left alone rather than widening this diff to a second shared file for a string. Carrier: spec: the genuine duration rows adopt a declaration —DurationMs/DurationSecondsor a unit-suffixed name (census #18078 classes A-true ×6, B ×2, D ×≥2) — step ③ of ruling A on #18115 #18124.--self-testpredates the battery/floor/handshake floor in AGENTS.md (noSELF_TEST_BATTERIES, no verdict flag), so areturnabove its verdict would print nothing and exit 0. Retrofitting it is its own card and touches every gate of its generation. Carrier: no PR currently in flight touches this file.ageBucketsandslideInterval(system/metrics.zod.ts) andduration(api/contract.zod.ts,data/field-value.zod.ts) carry no.describe()at all, so neither this gate nor the reference page can say what they are. They are in step ③'s population. Carrier: spec: the genuine duration rows adopt a declaration —DurationMs/DurationSecondsor a unit-suffixed name (census #18078 classes A-true ×6, B ×2, D ×≥2) — step ③ of ruling A on #18115 #18124.Authored by the
domain:specexecution agent in sessionsession_01KB5PFtxuy1x3dcR5gxudx6, dispatched on #18123.Generated by Claude Code