Skip to content

spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list - #18486

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-18123-duration-census-admits-by-declaration
Sep 16, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-18123-duration-census-admits-by-declaration

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Part of #18123 — step ② of ruling A on #18115, executing batch #139 item 3 (director seat, maintainer 「同意」 2026-09-16T01:57Z): 「名字表退休」.

⚠️ Deliberately not a closing keyword: one acceptance item on the card is answered conservatively rather than literally, and the reviewing seat should rule on it before the card closes. It is named at the bottom.

⚠️ Second round. The isolated at-tier contract review (PR comment 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.

  • the token-set constant and the predicate that read it are deleted. A self-test case reads this file's own source and asserts both identifiers are absent — with a positive control, and with the identifiers assembled from fragments so the assertion cannot match itself.
  • the closed duration vocabulary (DurationMs / DurationSeconds, landed by step ① in spec: closed duration types DurationMs / DurationSeconds beside EpochMs — step ① of ruling A on #18115 (declared shape for the unit-in-key census) #18122) is read as an admission channel, exactly the way the EpochMs instant 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 as duration-unit-contradicts-schema.
  • a dimensionless schema marker joins externalVocabulary in the same reader, under the same literal-only validation (an empty or computed value declares nothing and exempts nothing).
  • --list no 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 "--list baseline 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

reading before (85c6d76ec4) after (77d8a5337f)
census rows (--list) 208 185
numeric keys scanned 1036 1036
source files walked 2507 2507
offenders 0 0
declared EpochMs instants 5 5
declared externalVocabulary mirrors 11 11
declared duration types 0
declared dimensionless numbers 0
--self-test cases 78 (exit 0) 103 (exit 0)

Census 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:

counts (9)        reflectionInterval · slidingWindowSize · contextWindow · minContextWindow
                  summariesStale · totalSummariesStale · snapshotInterval · snapshotRetention · staleKeys
multipliers (5)   backoffMultiplier  x5  (integration/connector, kernel/events/handlers,
                                          shared/retry-policy, system/logging, system/worker)
instant (1)       shared/connector-auth.zod.ts  tokenExpiry   ("Token expiry timestamp")
unit-nowhere (8)  api/contract duration · data/driver idle · data/field-value duration
                  kernel/plugin-lifecycle-advanced shutdownTimeout · kernel/plugin-security-advanced maxAge
                  system/auth-config updateAge · system/metrics ageBuckets · system/metrics slideInterval

⚠️ The ruling predicted 17; the census loses 23 — and both numbers are right. #18078's own controlled census recorded it first: 「11 + 5 + 1 = 17; 17 + 6 = 23」. The 23 is the MECHANICAL population (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:

api/contract.zod.ts:311                     duration          (no describe)
data/field-value.zod.ts:424                 duration          (no describe)
kernel/plugin-lifecycle-advanced.zod.ts:413 shutdownTimeout   "Maximum time to wait for graceful shutdown"
kernel/plugin-security-advanced.zod.ts:623  maxAge            (no describe)
system/auth-config.zod.ts:563               updateAge         "Session update frequency"
system/metrics.zod.ts:412                   slideInterval     (no describe)

⛔ No key is renamed or retyped here — that is step ③'s work, and the card fences it.

⚠️ One correction against a first pass of this PR's own analysis, recorded rather than quietly fixed: reading the 23 by their .describe() alone put data/driver.zod.ts:414 idle and system/metrics.zod.ts:224 ageBuckets among 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 offenders on 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 a ttlMs name → still name-unit-contradicts-prose.
  • z.union([DurationMs, z.string()]) → a z.union root, 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-describe class (#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, interval with 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 explicit COST of 退休 self-test cases, plus a "route back" case showing the same key typed DurationMs is 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 as unit-in-jsdoc-not-in-describe and is not refused here. It is not refused because refusing it reds latencyMs / frequencyHours on main (#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 #18075 hold 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 earlier judge() 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 a trap ... EXIT INT TERM with absolute paths.

ablation self-test cases that went red
delete the agreement carve-out (&& !jsdocUnits.some(...)) exit 1 2 — both DEFERRED to #18075 cases. Before this round: 0
empty the closed duration vocabulary exit 1 6 — composition corrected below
re-admit ONE retired name token (timeout) exit 1 3 — both RETIRED: cases and the undeclared-JSDoc case

⚠️ Correction to this PR's own first ablation table. The vocabulary leg was reported as 6 red including "the module-coupling case". That case is for (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 asserts durationType === 'DurationMs'. On the reviewed head the true count was 5.

⚠️ Measured while re-running it: under the vocabulary ablation the self-test registers 101 cases, not 103 — the two per-root module-coupling cases de-register along with the map. A printed case count is evidence, not proof; this gate predates the battery/floor/handshake discipline (noted below).

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 a trap ... EXIT INT TERM with absolute paths. Carve-out leg: HEAD blob 556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835, mutated blob a17c4174f20555fa42ef759f46de7f2c46f473b0, restored blob 556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835 == HEAD, git diff HEAD exit 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 cases
  • tsx scripts/check-duration-unit-keys.ts → exit 0, 185 unit-declaring numeric key(s) ... zero offenders, no baseline
  • gate families derived from the real change set with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (56 commands) — all 56 run, all exit 0
  • exit codes captured by redirect-then-$?, never through a pipe

Changeset: skip-changeset, measured

packages/spec is published, but its files[] is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.jsonscripts/ is not in it. The diff is one file under packages/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: DurationMs returns 14 hits over the same published pathspec, so the zeros are readings rather than a broken probe.

Acceptance notes

Authored by the domain:spec execution agent in session session_01KB5PFtxuy1x3dcR5gxudx6, dispatched on #18123.


Generated by Claude Code

…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>
@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 16, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 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 — 0 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 2f11e2db7bfe4bfb5cd94e1590bea8941729dd09packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 77d8a5337f2a346113dd46e3717e84a07755d9c3

① Derived judgments

  1. Name-shape list retired (DURATION_SHAPED_TOKENS + isDurationShaped() deleted; a bare name-shaped z.number() is neither listed nor judged) — RIGHT. Governing text: [Decision] what counts as a duration for the unit-in-key census — replace the 25-token name list with a declared shape? (class A 23→6 and class D ≥7 of census #18078; tranche 2 precondition of ruling C on #18080) #18115 option A definition line 「名字表退休。」, confirmed batch Fix test failures from schema evolution (CustomValidator.handler, TenantSchema removal) #139 item 3 「是」. Re-derived census on the same tree (2507 files, 1036 numeric sites, both blobs): 208 → 185, 23 removed, 0 added; every removed row reads name=[] prose=[] jsdoc=[] durationType=undefined instant=false — unit-free on all four channels; positive controls on the 185 that stayed: name 169 / prose 160 / JSDoc 75 / instant 5 / mirror 11 (dark control fortnights 0). The 23 = spec: measure the option-1 population batch #65 declined for being unmeasured — duration keys whose unit is named only in JSDoc, or nowhere at all (report-only census) #18078's 17 dimensionless + 6 genuine unit-nowhere durations (step ③'s). Offenders 0 on both blobs; verdicts differing between base and head on any live site: 0.
  2. Closed duration vocabulary as an admission root, chain-walked like EpochMsRIGHT. DurationMs.or(z.string()), (DurationMs as any).describe(…), DurationMs!.optional(), DurationMs.pipe(z.number()) all resolve to the duration root and both contradiction directions are refused (duration-unit-contradicts-schema). Boundary (pre-existing shape, same as EpochMs): z.union([DurationMs,…]), z.optional(DurationMs), z.string().or(DurationMs), an aliased import (Ms), a namespaced shared.DurationMs are all outside the population; only z.union is pinned. A locally shadowed const DurationMs = z.string() IS admitted as a declared duration (identifier-by-name, pre-existing for EpochMs).
  3. Describe prose kept as an admission channel (reading A), acceptance item (e) not taken literallyRIGHT, independently. (i) Literal B repeals spec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, unit-less tenant timeouts #14478 ruling B's founding rule (ttl + "in seconds"): measured, the base blob carries 12 expect() cases whose expected verdict is unit-in-prose-not-in-name plus the walk-fixture case that counts 2 such offenders = 13 (the round said 11; head has 14+1); true B mutation on head reds 15 cases. ⚠️ Correction to the seat and the round: 「one line in declaresUnit() flips it」 is falsedeclaresUnit is a census/reporting predicate only; scanTree judges every numeric site with judge(). Flipping declaresUnit alone gives census 174 while the founding specimen is STILL refused (measured) — an incoherent gate; B needs the judge prose branch deleted too. (ii) All 11/11 externalVocabulary mirrors have keyUnits=[] and are prose-admitted; the 11 rows B drops relative to A are exactly those mirrors (prose-only non-mirror rows: 0) — under B the marker is read for nothing. (iii) Arithmetic: A drops 23 = 17 「无单位数自然出列」 + 6 「漏网的要么改用类型、要么改名」; B drops 34 with 11 unexplained departures the ruling never mentions ⇒ the ruling describes A. Item (e)'s own citation is 「batch Add comprehensive test coverage for ObjectStack spec protocols - 100% coverage achieved #65 control」, which is the JSDoc channel — and JSDoc still never admits (pinned: 'the divergence class needs a DECLARATION', 'a JSDoc that names no unit adds nothing'). Read as describe, (e) is a premise error in the card ⇒ maintainer one-liner, as the seat proposes. ⚠️ Attribution correction: the sentence 「Both classes stay VISIBLE in the census… An exemption nobody can see is the ledger this ruling refused」 the seat cites as [#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 is the gate's own header (base :141), not text on [#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 or in spec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, unit-less tenant timeouts #14478 5548763981.
  4. JSDoc-divergence branch re-seated on keyUnits>0 && jsdocUnits>0 && !agreeWRONG AS DECLARED (behaviour tolerable, declaration false). Fixtures through both judge()s: (a) bare list-shaped name + JSDoc unit (timeout/window/interval) — lost, declared ✓; (b) list-shaped stem + unit suffix + JSDoc naming the SAME unit + describe silent (timeoutMs + "Execution timeout in milliseconds" + describe 'Maximum execution time'; intervalSeconds + "in seconds" + no describe) — base REFUSED unit-in-jsdoc-not-in-describe, head passes — undeclared. The PR body's sentence 「its entire live population was keys like timeout, window and interval — names that declare nothing」 is false as a statement of the base predicate (base's guard was durationShaped, which timeoutMs satisfies). Batch Add comprehensive test coverage for ObjectStack spec protocols - 100% coverage achieved #65's ruled class has no name carve-out; [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 (open, p2 Bug, graded by this same seat 2026-09-15) holds the agreement shape SHOULD be refused and asked for exactly the timeoutMs+JSDoc-ms+no-describe fixture as a positive control — head makes it pass instead, and the judge comment (~:800-806) asserts 「Refusing there would manufacture offenders out of agreement … the measured proof」, adjudicating [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 while the body says [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 is 「not pre-empted」. Mutation: deleting the carve-out && !site.jsdocUnits.some((u) => site.keyUnits.includes(u)) → self-test exit 0, 0 red — the guard is unpinned in either direction (the [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 M1 vacuity shape). The carve-out is defensible ONLY as a sequencing accommodation (refusing agreement rows reds latencyMs / frequencyHours on main, [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's ordering constraint) and must be declared as deferred, not argued as correct. (c) non-list stem + JSDoc DISAGREES with name (latencyMs + "seconds") — newly refused on head — declared ✓.
  5. dimensionless marker reader, zero keys markedRIGHT in mechanism and in the zero. Ruling frame 3 names the in-schema marker beside externalVocabulary; literal-only (template literal accepted, as const refused), refusable on name contradiction (recentFailuresMsname-unit-contradicts-prose; on a DurationMs/EpochMs key the marker changes nothing); ablation drop → 1 red. Marking the 17 would be inert: with the list retired none of them is in the census (dimensionless is only read on a prose-admitted key), and src/**/*.zod.ts IS in files[] (201 tarball entries) so marking would turn skip-changeset into patch for zero enforcement. Cost noted: the marker exempts a genuine offender with any non-empty string (ttl + "in seconds" + dimensionless: 'x' → pass) — identical to externalVocabulary's shape, ruling-sanctioned, visible in --list. Half-built: scripts/lib/schema-section.ts renders externalVocabulary (:256) and nothing for dimensionlessspec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, unit-less tenant timeouts #14478 B made the docs half part of the mechanism.
  6. --list / summary line (no name-matched rows; declared durations printed beside instants) — RIGHT: 185 unit-declaring numeric key(s) … 0 declared duration type(s) (\DurationMs`/`DurationSeconds`), 5 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s), 0 declared `dimensionless` number(s). No committed baseline exists to shrink (git ls-filesduration-unit` = the script + changesets/migrations only).

② Semver level

skip-changeset — correct. Rule: AGENTS.md § Post-Task Checklist step 3 (AGENTS.md:1040-1043): 「⛔ never skip-changeset: that label is for a diff that publishes nothing from any released package」. Re-derivation on what ships: the diff is one file, packages/spec/scripts/check-duration-unit-keys.ts (+411/−100), 0 .changeset/ entries; @objectstack/spec@17.4.0 files[] = dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json; npm pack --dry-run --ignore-scripts --json at head → 271 entries, 0 under scripts/, 0 naming the file (lit control: 201 src/**/*.zod.ts entries); import statements from src/** referencing the script: 0 (lit control: 89 ./lib/ imports in scripts/; the 16 src/ hits are prose mentions). Clause-②: card claim comment 5699630403 carries Clause-②: yes, card body says Clause-②: no, PR body carries no Clause line; node scripts/pm/check-clause2-carriers.mjs --pair 18486 → exit 0, 「readable in the fixed spelling and both carriers agree」, needs:contract-review on both carriers. ⚠️ Step 3's 「Clause-②: yes takes at least minor」 presupposes something publishes; .claude/skills/pm-dispatch/SKILL.md:514 「条款②只指已发布契约面」 says the accurate value for a gate-only diff is no — the yes is a routing over-declaration and does not move the level; skip-changeset stands on what ships.

③ Boundary flags

Implemented-by: claude/issue-18123-duration-census-admits-by-declaration
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL

Required changes for PASS (documentation and pins only; ⛔ no behaviour change, skip-changeset unchanged):

  1. PR body § 「What the retirement COST」: delete 「its entire live population was keys like timeout, window and interval — names that declare nothing」 and add the fourth repealed shape verbatim: 「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 as unit-in-jsdoc-not-in-describe and is not refused here. It is not refused because refusing it reds latencyMs / frequencyHours on main ([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's ordering constraint: remediation before widening), ⛔ not because agreement is not an offence — that is [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's open question.」
  2. Header paragraph 「⚠️ WHAT THE NAME-LIST RETIREMENT COST THIS CLASS」 (head ~:113-126): same correction — the retired guard was durationShaped, whose reach included unit-suffixed names; name both repealed shapes.
  3. judge() comment (head ~:797-806): replace 「Refusing there would manufacture offenders out of agreement, and the two rows … are the measured proof of it」 with 「deferred to [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: refusing the agreement shape today reds the two live rows on main; whether it is an offence is that card's question」.
  4. Self-test: add two expect() cases pinning the two fixtures in 1. as '' today, labelled DEFERRED to #18075 (so deleting && !site.jsdocUnits.some((u) => site.keyUnits.includes(u)) goes red — today it reds 0 cases), and tighten :1094 to assert sites[0].durationType === 'DurationMs'.
  5. Re-run --self-test (expect 103 cases, exit 0) and the gate (expect 185 / 0 offenders, exit 0); PR body numbers updated accordingly.

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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 750aa5b0954a31434d6c6df428478fad8b6035ed

① Derived judgments

  1. Name-shape list retired (DURATION_SHAPED_TOKENS + isDurationShaped() deleted; a bare name-shaped z.number() is neither listed nor judged) — RIGHT. Governing text: [Decision] what counts as a duration for the unit-in-key census — replace the 25-token name list with a declared shape? (class A 23→6 and class D ≥7 of census #18078; tranche 2 precondition of ruling C on #18080) #18115 option A definition line 「名字表退休。」, batch Fix test failures from schema evolution (CustomValidator.handler, TenantSchema removal) #139 item 3 (card comment 5690865034, maintainer 「同意」 2026-09-16T01:57Z). Re-derived on three blobs (base ce7605e4 = origin/main's blob, r1 c646c682, head 556ad8fa): census 208 → 185 → 185; 23 removed, 0 added, the 23 named row-for-row in the PR body match my comm diff exactly; live-tree differential base→head over 2507 files / 1036 numeric sites: 23 admissions lost, 0 gained, 0 verdict differences, offenders 0/0. Fixtures: timeout/window/interval/ttl/age/backoffMultiplier with no unit anywhere read ADMIT:pass on base and out:pass on head. Retirement pin: the three identifiers read 0/0/0 lines at head against 3/2/6 on base (lit), assembled-fragment control line present (1); the pin bites — a comment naming the identifier reds exactly one case (RETIRED: this file no longer contains…).
  2. Closed duration vocabulary as an admission root, chain-walked like EpochMsRIGHT. Fixtures base→head: DurationMs plain (no sites)ADMIT:pass; DurationMs + describe seconds, DurationSeconds + name Ms, DurationMs.or(z.string()) + describe seconds, DurationMs + JSDoc seconds all → duration-unit-contradicts-schema on head only. Boundary (pre-existing, identical to z.optional(EpochMs)): z.optional(DurationMs), shared.DurationMs, aliased Ms, z.string().or(DurationMs) → 0 sites; only z.union is pinned. Channel pinned: emptying the map reds 6 at head (95 green + 6 red = 101 registered; the two per-root module-coupling cases de-register with the map, 2 → 0, disclosed in the PR body) versus 5 at r1 (94 + 5 = 99); the sixth is exactly admitted by TYPE: a DurationMs key needs no unit in its name; red-at-r1-not-at-head: 0.
  3. Describe prose kept as an admission channel (reading A), acceptance (e) read as the JSDoc control it citesRIGHT, not re-adjudicated beyond the numbers. My own count on the head census: 16 rows carry [name: -]; 5 are EpochMs instants, 11 are externalVocabulary mirrors, 0 are prose-only non-mirror rows ⇒ literal B drops exactly the 11 mirrors (census 174) and reads the marker for nothing, and drops 34 against the ruling's 「17 个无单位数自然出列」; A drops 23 = 17 + 6. JSDoc never admits: pinned (the JSDoc does NOT satisfy the rule, the divergence class needs a DECLARATION, a JSDoc that names no unit adds nothing). The maintainer one-liner on (e)-as-describe is still unanswered on the card (last comment 17:05Z); the shipped reading is the narrower accept set of the two and leaves every standing ruling intact, so it does not block landing.
  4. JSDoc-divergence branch re-seated on keyUnits>0 && jsdocUnits>0 && !agree, agreement half DEFERRED to [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 #18075RIGHT AS DECLARED; behaviour byte-identical to the reviewed head. r1→head differential: 37 fixtures 0 differences; live tree 0 admission / 0 verdict differences; --list and gate output cmp exit 0; the r1→head diff touches one header paragraph (:114-145), the judge() comment block (:818-826, all //) and the self-test only. Three repealed/added shapes, each measured base→head: (a) bare timeout/window/interval + JSDoc unit: ADMIT:unit-in-jsdoc-not-in-describeout:pass, declared header :123-128, pinned as three COST of 退休 cases; (b) timeoutMs + JSDoc ms + describe none and intervalSeconds + JSDoc seconds + no describe: ADMIT:unit-in-jsdoc-not-in-describeADMIT:pass, declared header :130-140 as DEFERRED, pinned by the two DEFERRED to #18075 cases; (c) latencyMs + JSDoc seconds (disagree): ADMIT:passADMIT:unit-in-jsdoc-not-in-describe, declared header :142-145. The pin bites in both directions: deleting && !site.jsdocUnits.some((u) => site.keyUnits.includes(u)) at head → self-test exit 1, 101 green + 2 red = 103 registered, the 2 red being exactly the two DEFERRED cases, gate exit 1 on packages/spec/src/ai/usage.zod.ts:52 latencyMs and packages/spec/src/system/tenant.zod.ts:603 frequencyHours; the same mutation at r1 → exit 0, 101 green, 0 red (the record's premise confirmed); deleting the whole branch at head → 3 red (both REFUSED (divergence) positives + REFUSED: a mirror whose NAME does carry a unit…), DEFERRED pair stay green, 103 registered. Argument residue: manufacture offenders survives once at :1257, about //-comment / enclosing-JSDoc over-firing, not the carve-out; measured proof, diverges from nothing, not an offence read 0 in the file; the judge() sentence at :818-820 still states the shape's premise (the reference page prints the key name) but is immediately followed by 「⛔ This half is DEFERRED to [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, ⛔ not argued correct here」 — judged an honest deferral, named here so the seat sees it. PR body: the one not an offence hit is 「⛔ not because agreement is not an offence — that is [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's open question」. [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 is open (pm:queue, p2) and its comment 5700898658 restates its fix as 「flip DEFERRED → POSITIVE after the two describe-only remediations」 with a dispatch hold while spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list #18486 is open.
  5. dimensionless marker reader, zero keys markedRIGHT in mechanism and in the zero. Fixtures: marker waives the rename (recentFailures base unit-in-prose-not-in-name → head pass), recentFailuresMs still name-unit-contradicts-prose, empty/computed marker exempts nothing; cost visible and ruling-sanctioned: ttl + "TTL in seconds" + dimensionless: 'x' → pass (same shape as externalVocabulary). Tree: 0 .zod.ts files carry dimensionless: (lit externalVocabulary: 11). Renderer half absent: scripts/lib/schema-section.ts 5 externalVocabulary lines / 0 dimensionless — see F7.
  6. --list / summary line — RIGHT: head prints 185 unit-declaring numeric key(s) across 2507 source file(s); 1036 numeric keys in all; 0 declared duration type(s) (DurationMs/DurationSeconds), 5 declared EpochMsinstant(s), 11 declaredexternalVocabularymirror(s), 0 declareddimensionless number(s); r1 and head byte-identical. No committed baseline exists to shrink.

② Semver level

skip-changeset — correct. Rule on origin/main (AGENTS.md blob fb8bae3e, § Post-Task Checklist step 3, line 1066): 「patch changeset — never none, and ⛔ never skip-changeset: that label is for a diff that publishes nothing from any released package.」 (the shared checkout's blob f04446be carries the same sentence at :1042). Re-derivation on what ships, at head: npm pack --dry-run --ignore-scripts --json in packages/spec (@objectstack/spec@17.4.0, files[] = dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json) → 271 entries, 0 under scripts/, 0 naming the file; lit control 201 src/**/*.zod.ts entries. Diff base→head: 1 file (packages/spec/scripts/check-duration-unit-keys.ts, +464/−100), 0 published paths, 0 .changeset/ entries. Import-statement importers of the script under packages/spec/src: 0 (the 2 name hits are prose strings in migration entries; lit control 48 ./lib/ importers in scripts/). Clause-②: node scripts/pm/check-clause2-carriers.mjs --pair 18486 (GET-only, verified) → exit 0, 「the clause-② declaration is readable in the fixed spelling and both carriers agree」; the claim comment's Clause-②: yes against the card body's no is a routing over-declaration that moves no level because nothing publishes.

③ Boundary flags

Implemented-by: claude/issue-18123-duration-census-admits-by-declaration
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — PR #18486, card #18123, head 750aa5b0954a31434d6c6df428478fad8b6035ed

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T17:27Z. Readings are command output from this window, ⛔ not recall.

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 184860 of 1 path hits the registerNOT 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

⚠️ ③ is read after the ready flip and after the carrier strip — a strip re-triggers Check Changeset and a ready flip starts Governed Surface Queue Guard.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⚠️ The ready flip is REFUSED by the platform — endpoint and status recorded, PR left draft, retry armed

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T17:30Z. Recorded because the provenance comment above said ready would be flipped; a flip that did not happen and is not written down is a half-state.

POST /repos/objectstack-ai/objectstack/pulls/18486/ccr/ready_for_review
  17:28:02Z  HTTP 503   read-back draft=True
  17:28:15Z  HTTP 503   read-back draft=True
  17:28:19Z  HTTP 503   read-back draft=True
  17:28:24Z  HTTP 503   read-back draft=True
  17:28:33Z  HTTP 503   read-back draft=True
  17:30:02Z  HTTP 503   read-back draft=True     (after a 45s pause)

The read-back is what makes this a reading. 「状态码不作数,读回才作数」 — here both agree, and they agree in the direction that matters: the PR is still draft, so nothing half-applied.

What it is NOT, each ruled out by measurement rather than by assumption

hypothesis reading that rules it out
credential dead or repo-scoped out GET /rate_limitcore 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.json denies 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/18486 with {"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. ⚠️ If the route is still refusing at the next seat check-in, this comment is the record and ⛔ no second comment is owed for the same refusal.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 17:49
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 8ca7aaf Sep 16, 2026
57 of 58 checks passed
@os-warren
os-warren deleted the claude/issue-18123-duration-census-admits-by-declaration branch September 16, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants