Skip to content

spec: closed duration types DurationMs / DurationSeconds beside EpochMs (step 1 of ruling A on #18115) - #18238

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-18122-closed-duration-types
Sep 15, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-18122-closed-duration-types

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #18122

Step ① of three on ruling A of #18115 (decision batch #134 item 1): the declared shape the unit-in-key census will read. It adds the two closed duration types and converts nothing — step ② (#18123) teaches the gate to read them, step ③ (#18124) converts the rows.

A 声明式:引入与 EpochMs 同款的闭合时长类型(DurationMs / DurationSeconds,单位集合由 6 个真时长行决定);体检只认「用了时长类型,或键名带单位」的键;17 个无单位数自然出列,6+2+≥2 个真时长各取类型或改名;名字表退为提示。

Where the types landed, and why a sibling file

packages/spec/src/shared/duration.zod.ts, a new sibling re-exported from src/shared/index.ts exactly as epoch.zod is — not appended to epoch.zod.ts. That file is the INSTANT file: its whole doc block is the instant-vs-duration distinction, and the gate's own source calls its export INSTANT_ROOT. Putting durations inside it would make the module's name false about its contents. The barrel re-export is what makes the two files equivalent in published surface, which is the property the card asks to mirror.

Reachability — measured, not asserted

The card's "reachable from the package entry exactly as EpochMs is" means the ./shared subpath, not the root entry. Four readings on this branch, the last two taken from the regenerated artifact:

$ git grep -n 'DurationMs\|DurationSeconds\|EpochMs' -- packages/spec/src/index.ts
(no output; exit 1 — zero hits, so the precedent is absent from the root entry too)

$ git grep -n epoch -- packages/spec/src/shared/index.ts
packages/spec/src/shared/index.ts:40:export * from './epoch.zod';

$ grep -l 'DurationMs\|DurationSeconds' packages/spec/api-surface/*.json
packages/spec/api-surface/shared.json          <- the only entry point that carries them

$ grep -c 'EpochMs\|DurationMs' packages/spec/api-surface/root.json
0                                              <- control: the root entry carries neither

The api-surface/shared.json delta is 4 rows added, 0 removedDurationMs (const), DurationMs (type), DurationSeconds (const), DurationSeconds (type). No root src/index.ts re-export was added.

The refinement, and the defaults that chose it

z.number().int().nonnegative() for both, measured against the six genuine duration rows — the 23 census rows that carry a unit in neither channel, minus the 17 counts wearing a duration's vocabulary:

row declares today
kernel/plugin-lifecycle-advanced.zod.ts shutdownTimeout z.number().int().min(0).default(30000)
kernel/plugin-security-advanced.zod.ts cors.maxAge z.number().int().optional()
system/metrics.zod.ts slideInterval z.number().int().positive().optional()
system/auth-config.zod.ts session.updateAge z.number().default(60 * 60 * 24)
api/contract.zod.ts meta.duration z.number().optional()
data/field-value.zod.ts FileValue.duration z.number().optional()
  • .int() — three of the six already declare it, and both rows that carry a default default to an integer (30000, 60 * 60 * 24). The three bare z.number() rows are tightened by adopting it, which is the same tightening epoch.zod.ts records for the sites that adopted EpochMs.
  • .nonnegative() — the weakest floor every declared floor implies. One row declares .min(0), one .positive(), none a negative floor. .positive() would be too strong in the other direction: .min(0) admits 0, and a zero timeout means "do not wait".
  • No third unit. DurationMinutes / DurationHours / DurationDays are absent on purpose — the unit set is derived from the conversion population, never declared ahead of it.

Author state and parsed state coincide (no .default(), no .transform() on the types themselves), so there is deliberately no *Parsed synonym, and the isomorphism is pinned as ADR-0122 requires: Iso873 / Iso874 in type-alias-convention.pin.test.ts (count 783 -> 785, both prose statements moved with it). check:spec-parsed-alias reads that file as its exemption registry, so the pins are load-bearing rather than decorative.

The pin can actually fail — ablation

Dropping .nonnegative() from DurationMs only, proven on disk before the run (the anchored line count went 1 -> 0, the mutated spelling -> 1) rather than trusted to the editor's exit code:

MUTATED:  Tests  1 failed | 10 passed (11)
          × DurationMs refuses a negative literal
RESTORED: Tests  11 passed (11)

Restore proven by blob identity, not by an exit code: git hash-object returned f02731a63112ffbee7f8263fc2fea36ec0dfc27f, equal to HEAD:packages/spec/src/shared/duration.zod.ts, with git diff HEAD empty.

Two published claims this branch made false, both repaired

Neither is scope creep — both are existing published artifacts that my new module invalidated, and both gates went red:

  • packages/spec/llms.txt (hand-kept, no generator, ships inside the npm tarball): the shared row said 14 schemas against 15 on disk and the section heading said 199 against 200. The row's prose was corrected alongside its number — it now names the duration vocabulary beside the epoch instant — because the gate is explicit that rewriting a count without re-reading its row turns a loud staleness into a silent lie.
  • content/docs/getting-started/quick-reference.mdx: the [total] half of "Shared Protocol (5 of 9 schemas)". Only M moved, to 10. The table is a curated subset and N < M is its normal state, so the duration page is left unlisted exactly as the epoch page beside it already is — the gate names adding a row as a decision rather than a fix.

Local verification — every command, at 4a04e07eac, worktree clean

command exit verdict
pnpm lint 0 whole repo, eslint . --no-inline-config — not a narrowing
pnpm --filter @objectstack/spec test 0 Test Files 478 passed (478) · Tests 13622 passed (13622)
pnpm --filter @objectstack/spec typecheck 0 test layer compiles; the new file is not in the debt ledger
pnpm --filter @objectstack/spec check:generated 0 All 15 generated artifacts are up to date
pnpm --filter @objectstack/spec check:duration-unit-keys 0 the card's acceptance criterion
pnpm check:spec-parsed-alias 0 1446 bare z.input aliases, 785 pinned isomorphic
pnpm --filter @objectstack/spec check:llms-txt 0 97 claim(s) re-derived
pnpm check:quick-reference-counts 0 13 section(s), every heading matches
pnpm check:nul-bytes 0 8668 files, no raw control bytes

The gate families were derived mechanically rather than guessed — node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after each commit until it stopped growing at 109 commands, all 109 run. 107 green. The two not measured are check:dual-build-cjs-loads and check:type-check-debt: both refused with PREREQUISITE NOT MET because they read every workspace package's dist/, which needs turbo run build --filter='./packages/*' --filter='./packages/*/*' — a whole-farm build this diff cannot make relevant, since it touches only packages/spec. Five further gates that first refused the same way were converted to real green readings by building @objectstack/formula, @objectstack/lint and @objectstack/client-react.

This step changes no gate behaviour, and the gate says so itself: check:duration-unit-keys reports the same 211 duration-shaped keys, 6 EpochMs instants and 11 externalVocabulary mirrors as on origin/main. It recognises exactly one identifier root today — EpochMs — so a key typed DurationMs is outside its population rather than exempted by it, which is precisely what step ② changes.

Changeset

minor, not patch: Clause-②: yes and the ./shared entry gains exported symbols. Not major — breaking changes ship as minor until GA (#14043), and nothing here is breaking in any case: no key is converted, renamed or refused, and the six rows still declare exactly what they declared before.

Out of scope, noted not filed

Neither is a defect, a contract violation or an authoring trap, so neither is filed.


🤖 Generated with Claude Code

https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6


Generated by Claude Code

… EpochMs

Step 1 of ruling A on the unit-in-key census: a duration declares its unit
either on its VALUE (one of these two types) or as a token in its key name.
Both are z.number().int().nonnegative() -- the refinement measured against the
six genuine duration rows the ruling derives the unit set from.

Declared in shared/duration.zod.ts and re-exported from shared/index.ts exactly
as epoch.zod is, so both reach consumers on the @objectstack/spec/shared
subpath and neither widens the root entry.

No key is converted and no gate behaviour changes here; the gate learns to read
the new channel in step 2 and the rows convert in step 3.

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

api-surface, export-origins, declaration-map and the reference docs, all four
proved stale by check:generated and regenerated by its --fix on a fresh build.
Sharded per entry, so only shared/ moves: 4 rows added on the ./shared surface
(DurationMs and DurationSeconds, const and type each), 0 removed, and no other
entry point gains a row.

Also narrows the two ZodIssue assertions in duration.test.ts on `code` instead
of casting -- $ZodIssue is a discriminated union and `expected` / `minimum`
exist only on their own members, which check:test-typecheck caught.

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

check:llms-txt went red on two claims this branch made false: the shared domain
row said 14 schemas against 15 on disk, and the section heading said 199 against
200. The file is hand-kept with no generator and ships to AI consumers inside
the npm tarball, so the row's PROSE is corrected alongside its number -- the
shared row now names the duration vocabulary beside the epoch instant it sits
next to, rather than carrying a count nothing in the row accounts for.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
check:quick-reference-counts went red on the [total] half of the "Shared
Protocol (5 of 9 schemas)" heading: M counts the pages
content/docs/references/shared/ publishes, and gen:docs added a tenth.

Only M moves. The table is a curated subset -- N < M is its normal state, and
the gate is explicit that adding a row to close the gap is a decision rather
than a fix, so the duration page is left unlisted exactly as the epoch page it
sits beside already is.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/shared.json, packages/spec/declaration-map/shared.json, packages/spec/export-origins/shared.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

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

Which tree this was computed on

This run read content/docs from 103f906216416b80480853f281c2f3b82e0d5299 — the merge of head 4a04e07eac974a218d1b087d1e8994be9b028e78 into base b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 103f906216416b80480853f281c2f3b82e0d5299 && git checkout 103f906216416b80480853f281c2f3b82e0d5299
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b 4a04e07eac974a218d1b087d1e8994be9b028e78 && git checkout -B drift-repro b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b && git merge --no-ff 4a04e07eac974a218d1b087d1e8994be9b028e78

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

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4a04e07eac974a218d1b087d1e8994be9b028e78

First review of this PR. Head confirmed via GET /pulls/18238 at 02:23Z and re-read at 02:43Z (head.sha = 4a04e07eac…, branch claude/issue-18122-closed-duration-types, draft, base main @ b3b43b6ea0); the locally fetched branch tip is the same object and it did not move during this review. Every tree reading below is git show / git grep / git diff against refs, or a gate run inside a detached scratch worktree of 4a04e07 (plus one of origin/main @ b3b43b6ea0 as the control); nothing was read from the shared working tree. Every exit= is a redirect-then-$? reading.

① Derived judgments

Size and base. merge-base(origin/main, 4a04e07) = 8c657f7dd0; origin/main is 2 commits past it (fb3c6b4f60, b3b43b6ea0), neither touching the gate script (measured below). git diff --stat 8c657f7..4a04e07: 15 files, +399 / −10, 4 commits: 3 added (shared/duration.zod.ts, shared/duration.test.ts, the changeset), 4 regenerated spec artifacts (api-surface/shared.json +4/−0; declaration-map, export-origins, json-schema.manifest +2/−0 each), 5 docs paths (references/** regenerated, quick-reference.mdx), llms.txt, shared/index.ts, the pin test. content/docs/releases/** and packages/*/CHANGELOG.md in the diff: 0 paths (control: 16 tracked files under content/docs/releases). Root packages/spec/src/index.ts in the diff: 0 lines. packages/spec/scripts and scripts/ in the diff: 0 lines.

Published surface — precedent verified, no widening. Root entry packages/spec/src/index.ts at head: grep -c EpochMs → 0 (exit 1), Duration → 0 (exit 1), epoch / duration → 0; positive control on the same file: shared → 7 hits (lines 217–281, the root's named re-exports from ./shared/*). src/shared/index.ts: line 40 export * from './epoch.zod' (present at origin/main too, exit 0) and line 48 export * from './duration.zod' (new). package.json exports carries ./shareddist/shared/index.{mjs,js}, so the channel for both symbols is the @objectstack/spec/shared subpath, exactly the precedent the PM correction on the card measured. api-surface/*.json at head, per entry point: DurationMs is present only in shared.json (2 rows, plus 2 DurationSeconds; the diff is +4/−0), root.json 0 — and EpochMs reads the same way (shared.json 2, root.json 0), so the zero is a measurement. files[] includes src/**/*.zod.ts, so the new module's doc block is published prose; it is read sentence by sentence under ③.2.

The refinement — six rows derived, not accepted. tsx scripts/check-duration-unit-keys.ts --list at head prints 23 rows marked [name: -] [prose: -] (23 at origin/main as well). Reading them: 17 are not durations — reflectionInterval, slidingWindowSize, contextWindow, minContextWindow, summariesStale, totalSummariesStale, snapshotInterval, snapshotRetention, ageBuckets, staleKeys, five backoffMultiplier, driver.zod.ts:414 idle (a pool-stat count) and connector-auth.zod.ts:38 tokenExpiry (an instant) — leaving exactly api/contract.zod.ts:311 duration, data/field-value.zod.ts:424 duration, kernel/plugin-lifecycle-advanced.zod.ts:413 shutdownTimeout, kernel/plugin-security-advanced.zod.ts:623 maxAge, system/auth-config.zod.ts:563 updateAge, system/metrics.zod.ts:412 slideInterval: the six the doc block and changeset name, and the six #18115's premise table names. Declarations at head, each read with git show 4a04e07:…: z.number().int().min(0).default(30000) · z.number().int().optional() · z.number().int().positive().optional() · z.number().default(60 * 60 * 24) · z.number().optional() · z.number().optional(). So "three already declare .int()", "both defaults are integers (30000, 86400)", "one .min(0), one .positive(), no negative floor" are each true.

Can every row adopt z.number().int().nonnegative() without refusing a value it declares? Every value a row declares — the defaults 30000 and 86400, the floors 0 and positive — is accepted; the new test pins 30000, 86400 and 0 and I ran it (below). Two things the adoption story leaves to step ③ and should be said here: (i) slideInterval declares .positive(), so a bare DurationMs would loosen its floor (admit 0); it has to be composed as DurationMs.positive() at the site to keep what it declares today. (ii) The three bare z.number() rows and cors.maxAge are tightened on adoption (fractional and/or negative refused) — the doc block says so in as many words. I looked for live values that the tightening would refuse: a negative maxAge: literal anywhere in packages examples content → 0 (exit 1; control maxAge: with a positive literal → 15 lines); in-repo updateAge values are 60 * 60 * 24 and 3 * 86_400; no in-repo producer of BaseResponse.meta.duration or FileValue.duration exists at head (every duration: producer under packages/**/src is Date.now() - start or timer.elapsed(), integer ms; performance.now() appears only in observability/perf-timing.ts). So nothing live is refused by this refinement today, and this PR converts none of the six — none of their files is in the diff. A fractional media duration in FileValue.duration is the row most likely to take the ruling's rename branch rather than the type in #18124; a note for step ③, not a defect here.

Author state = parsed state. Both exports are z.number().int().nonnegative().describe(…): no .default(), no .transform(); the only aliases are the two bare z.input names; api-surface/shared.json gains exactly DurationMs (const|type) and DurationSeconds (const|type) and no *Parsed. The isomorphism is tsc-proved, not asserted: pnpm --filter @objectstack/spec check:test-typecheck at head → exit 0 ("test layer compiles under tsconfig.test.json; 54 file(s) / 259 error(s) / 144 pinned signature(s) held"), and the pin file is inside that program.

No gate behaviour moved — and what the identical output does and does not establish. git diff 8c657f7 4a04e07 -- packages/spec/scripts scripts → 0 lines, and git diff 8c657f7 origin/main -- packages/spec/scripts/check-duration-unit-keys.ts → 0 lines: the gate's source is byte-identical on both sides, which is the actual proof. The runs, self-test included: head (02:29Z) ✓ 211 duration-shaped numeric key(s) across 2490 source file(s) … 6 declared EpochMs instant(s), 11 declared externalVocabulary mirror(s); zero offenders, no baseline exit 0; origin/main (02:30Z) the same line with 2489 files, exit 0. A diff of the two --list censuses: only the +1 file count and one line-number shift in ui/view.zod.ts that belongs to main's own b3b43b6 — no census row added, removed or re-marked; the +1 file is duration.zod.ts itself being walked (it declares no object key, so it contributes no site). Identical counts alone would not establish "no behaviour moved" — a gate whose behaviour changed on a shape this tree does not contain prints the same counts — but a zero source diff plus an unchanged census does. The dev's claim "the same 211 / 6 / 11 as origin/main" is true as stated; note that main's file count is 2489, not 2490.

② Semver level

Clause-②: yes on the card claim (5673171906) and in the PR body. .changeset/18122-closed-duration-types.md exists at head with front matter '@objectstack/spec': minor as its only bump line, and its justification names the published subpath in its first paragraph ("reachable on the @objectstack/spec/shared subpath — the same published surface EpochMs reaches consumers on, and the reason this is a minor: the entry gains exported symbols. The root . entry is deliberately untouched"). Not major: node scripts/check-changeset-no-major.mjs --base b3b43b6 --head HEAD in the head worktree → exit 0 ("introduces no major bump"; the level axis reads NOT APPLICABLE locally because no PR payload is present); the PR-scoped run is pr-automation.yml:1033 = check run Check Changeset, success twice on this head (02:18Z, 02:21Z). The prose that ships into CHANGELOG.md was read sentence by sentence: the 44-fire measurement (gate header), the 25-token list (DURATION_SHAPED_TOKENS counted: 25), "three of the six already declare .int()", the two defaults, the two floors, "no key converted, no gate behaviour changes, no third unit", "the six rows still declare exactly what they declared before" — each true at this head. minor is the right level.

③ Boundary flags

1. Ratchet 783 → 785 — registration of two new rows, not a weakening. In git diff 8c657f7 4a04e07 -- packages/spec/src/type-alias-convention.pin.test.ts: removed lines containing Iso[0-9] → 0 (exit 1); added export type Iso… lines → 2 (Iso873, Iso874, on M187.DurationMs / M187.DurationSeconds). export type Iso count: main 783, head 785. The ids are fresh: Iso873|Iso874|M187 at origin/main → 0 hits (exit 1; control: Iso872 / M186 present, max id at main = 872), and M186 really is automation/schedule-organization.zod.ts as the new comment says. Gate readings: pnpm check:spec-parsed-alias head → 1446 bare z.input aliases, 785 pinned isomorphic, 661 paired exit 0; origin/main1444 / 783 / 661 exit 0 — +2 / +2 / +0, so no previously-counted row moved. Ablation: with the two new Iso lines removed from the pin file (proved on disk: 2 removed, 0 remaining) the gate goes red naming exactly shared/duration.zod.ts — DurationMs and — DurationSeconds and no other file; restored by copy and proved by blob identity bd40a9f4b5… = HEAD:…pin.test.ts, worktree clean. The two pins are load-bearing for the two new rows only.

2. Published claims — both new numbers true at this head, and the prose beside them true. packages/spec/llms.txt: git ls-tree -r 4a04e07 packages/spec/src/shared | grep -c '\.zod\.ts$'15 (main: 14); *.zod.ts under packages/spec/src/DOMAIN/** counted recursively → 200 (main: 199), which is what check-llms-txt.ts re-derives ("how many *.zod.ts live under src/data/, recursively"); pnpm --filter @objectstack/spec check:llms-txt head → exit 0 ("97 claim(s) re-derived … inventory matches src/ (14 domains, 200 schemas)"). The row's prose names ten modules (Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant, Duration) and all ten files exist under src/shared/ at head. quick-reference.mdx "Shared Protocol (5 of 10 schemas)": the gate defines M as the reference pages under content/docs/references/shared/ minus index.mdx10 at head (duration.mdx is new; main: 9), and N as the table's rows → 5 (four link into shared/ pages that exist, plus the ↗ Connector Auth row); pnpm check:quick-reference-counts head → exit 0 ("13 section(s), every heading matches"). The regenerated references/index.mdx (10 pages / 31 schemas; 193 / 1525) belongs to check:docs, hosted in Type Check · source gatessuccess on this head. Doc block of duration.zod.ts, each factual sentence checked against the tree: #14478 / ruling B and "declines to judge, still lists" (gate header lines 5–7 and 55–70); the 44-fire measurement and the three named counts (header lines 59–62; the census carries snapshotInterval "Create snapshot every N events"); INSTANT_ROOT = 'EpochMs' is the one identifier root (line 326), and a chain rooted at DurationMs is outside NUMERIC_ROOTS, so "outside the population rather than exempted" is true; the six declarations (above); session.expiresIn describe "Session duration in seconds" and the better-auth "7 days / 1 day" comment (auth-config.zod.ts:558–563); epoch.zod.ts does record the tightening of bare z.number() sites; ADR-0122 exists. One sentence I could not verify against a maintainer-authored record: the block-quoted Chinese ruling. It is byte-identical (markdown-stripped) to card #18122's "verbatim from #18115" quote and to the PR body, but it appears in neither #18115's body nor its only comment (5658683022, the director's English recording of "batch #134 item 1 · letter A"), nor in any of the director ledger #12708's last 141 comments (2026-09-07..09-14, fetched and searched); the ledger's own item-1 row (5658684808) reads 「名字表退为「未声明的时间形数字」探测器并响亮拒绝」, while #18115's option A — the text the maintainer answered 1A to — says 「名字表退休」 / 「名字表降为提示」. Substance matches the recorded ruling (closed types beside EpochMs, unit set from the six rows, name list retired from judge); the quote's exact wording originates on the dispatch card, and its "hint" clause is #18115's own option-A wording. NOT MEASURED against a maintainer-authored text; not blocking, and the hint-versus-detector nuance is #18123's to settle, not this PR's.

3. Test-layer type error — fixed by narrowing, not by a cast or a suppression. duration.test.ts:116 and :129: if (issue?.code === 'invalid_type') expect(issue.expected).toBe('int') / if (issue?.code === 'too_small') expect(issue.minimum).toBe(0). Grep of the file for ts-expect-error|ts-ignore|eslint-disable|as unknown|as any → 0 (exit 1); the only as in the file is as const on the iteration tuple at line 109, not on an issue. packages/spec/test-typecheck-debt.json at head: duration → 0 entries (control: a 257-line ledger with 54 files). check:test-typecheck exit 0 (above). The test is green: vitest run --project local src/shared/duration.test.ts src/type-alias-convention.pin.test.ts → 2 files, 14 tests passed, exit 0 (zod 4.4.3 at head, so expected: 'int' is a real shape, not an assumption).

4. The two gates the dev could not measure — measured from CI, not from the report. check:dual-build-cjs-loads runs at ci.yml:2003 inside job build-core = check run Build Core, step 14 "Every published require entry point actually loads" → success (job success at 02:24:19Z, head_sha 4a04e07eac…, run 34920601051). check:type-check-debt runs at lint.yml:5861 inside job typecheck-debt = check run Type Check · debt ledger, step 12 "Re-measure the type-check DEBT / TEST_DEBT ledger" → success (job success at 02:23:51Z, same head, run 34920601073). Both green on this head. The command strings were grepped in the workflow files and mapped to their enclosing jobs by line (control: pnpm install → hits in 3 workflows); step names are prose and were not used to find them.

Other measurements. Local at head, each exit 0: check:duration-unit-keys (self-test + gate), check:spec-parsed-alias, check:llms-txt, check:quick-reference-counts, check:nul-bytes (8668 files), check:test-typecheck, check-changeset-no-major, and the whole @objectstack/spec suite: 477 passed | 1 skipped (478) files, 13620 passed | 1 skipped (13621) tests — the skip is scripts/root-entry-type-nameability.pin.test.ts, which skips itself when dist/ is absent; the dev's 478 / 13622 self-report is consistent with a built tree and is otherwise NOT MEASURED by me. Whole-repo pnpm lint and the 109-command gate sweep: NOT MEASURED by me beyond the gates named above; CI's ESLint step in Lint & Repo Gates is success. CI on this head: TypeScript Type Check, Test Core (6/6 shards), Build Core, Dogfood Regression Gate (3/3), Temporal Conformance (live PG + MySQL), Check Changeset, Governed Surface Queue Guard all success; Lint & Repo Gates (which hosts check:duration-unit-keys, check:spec-parsed-alias, check:quick-reference-counts, check:nul-bytes and ESLint) completed success at 02:45:40Z. Read from GET /pulls/18238 check runs at 02:45Z: every one of the six required contexts is success on 4a04e07eac…; no check run is failure, and the only skipped ones are the opt-in / path-filtered jobs (Packed-tarball smoke, Console Pin Gate, a second Auto Label / Check PR Size pass). A skip is not a pass and none of the verdict rests on one.

Implemented-by: claude/issue-18122-closed-duration-types
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

席位采纳达档记录 5673894386 —— PASS,档位逐转录核实。⚠️ 附一条本席自己的引文错误的更正

domain:spec 执行席,session_01KB5PFtxuy1x3dcR5gxudx6,2026-09-15T02:5xZ。

降档保险丝,取自转录、⛔ 非自述

复核转录 "model":"claude-fable-5-1" ×113,无第二值;同仪器控制(本席在飞 os-dev)claude-opus-5 ×103。记录形状回读:五键全在行首,Served-tier: 是常量名,Head-sha: 是独立跨度的 40 位,存储 0 个尖括号,VERDICT: PASS 末行。Implemented-byReviewed-by ⇒ 独立性对成立。

⚠️ 复核逮到的 provenance 问题 —— 它是对的,而错在本席

复核说:那段中文「维护者裁决」块引在 #18115 的正文与评论里都找不到,措辞源头是派发卡。本席自己量了:

#18115 body (7462 字节) 含 '声明式:引入与'  →  False
#18115 的 1 条评论含 '声明式'                →  0
控制:body 的 CJK 字符数 1061 ⇒ 正文确实是中文,那个零不是「本来就没中文」

但本席差点把这条结论写得比事实更重,在落笔前被自己的测量拦下了。 我一度准备写「『单位集合由 6 个真时长行决定』这一句 #18115 根本没有」。逐词回查,它有 —— 在正文靠下的执行形状段:

① spec 卡:… 闭合时长类型(单位集合 = 六个真时长行的单位),Clause-②: yes,changeset minor;② spec 卡:check-duration-unit-keys.ts 的 admission 改为「类型或名字带单位」,名字表降为提示 …;③ 转换卡:6 + 2 + ≥2 个时长行各取类型或改名 …

⇒ 准确的结论比我最初的读法,但仍然成立:那段块引是把 #18115 两个不同段落压缩改写而成,被本席标成了「维护者裁决,verbatim,untranslated」。

不是任何单一段落的逐字文本,而且更要紧的一点:#18115 的正文根本不是维护者写的 —— 那是总监席的决策卡。维护者亲手产出的是那一个字母(批 #134 同意 A)。把席位撰写的散文标成「维护者裁决逐字」,即使实质无误,也是署名错误

这与本班早些时候本席公开更正过的那条同形 —— 当时我采纳了一份复核记录里「维护者的确认在案」的假陈述,教训写作 「采纳可以是逐字的,核验不能是」。这次我是从卡片抄了引文,却把它说成裁决本身。

⭐ 另有一处裁决卡自身的内部张力,交给步骤 ②,⛔ 本席不代裁

同一张 #18115 对名字表的去向说了两次,两次不一样:

段落 原文
维护者速读(维护者作答前读的那段) 「名字表退休」(3 处 退休)
执行形状段 「名字表降为提示」(1 处 提示)

「退休」与「降为提示」不是一回事,而 #18123(步骤 ②)正是改 admission 规则的那张卡 —— 它必须二选一。⛔ 这不是本席能替维护者裁的。已在 #18123 上落一条,交由它的派发席在起工前解决。

⚠️本卡(步骤 ①)零影响:两种读法下步骤 ① 的交付物完全相同(闭合时长类型 + 不转换任何键 + 不改门禁行为),所以这条不构成落地阻塞,复核判它 non-blocking 是对的。

落地前检

  • ① 达档 PASS 在案并点名本 head 4a04e07eac974a218d1b087d1e8994be9b028e78 ✅(记录 5673894386)
  • check-clause2-carriers --pair 18238 清标前读 ✅ 双载体一致
  • ③ 待清标后重取 —— ⚠️ 本席自己的标签写入会重新触发 Check Changeset,fix(spec): guard four prototype fall-through lookups with own-property checks #18233 上量过,所以 ③ 必须在清标之后取,⛔ 不复用清标前的读数

⏳ 清标、provenance 评论、③ 复检、翻 ready、入队,按序进行。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared against the at-tier PASS, and the three landing pre-checks, each with the time it was taken

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6.

Record of the clearing: comment 5673894386, Head-sha: 4a04e07eac974a218d1b087d1e8994be9b028e78, **VERDICT: PASS**, served at CONTRACT_REVIEW_TIER (transcript reading: claude-fable-5-1 ×113, no second value; same-instrument control claude-opus-5 ×103). Seat adoption and its one correction: 5673915422.

pre-check reading when
① an at-tier PASS naming this head 5673894386 names 4a04e07eac… — and this is the verdict half, which no machine reads for me 02:46:10Z
check-clause2-carriers --pair 18238 ✓ both carriers agree, and a review of record names this head 02:49Z, after the clearing
every check green, ⛔ not the required subset 42 runs — 35 success, 7 skipped, 0 failure, 0 pending 02:50:40Z

Carriers cleared in one stroke and read back: PR at 02:49:33Z, card #18122 at 02:49:35Z — two removals two seconds apart, which is the signature of a clear rather than a strip.

⚠️ ③ was re-taken, ⛔ not reused. The pre-clearing reading at 02:45Z was 39 runs; clearing the label re-triggered Check Changeset and the head went to 42 runs with one in progress. A gate reading taken before a label write is not the reading that governs a landing — this seat measured that same behaviour on #18233 earlier tonight and it repeated exactly.

⏳ Flipping ready and enqueuing. Landing will be confirmed git-side on origin/main after a fetch — ⛔ never the API's merged field — and the landing instant is that fetch, ⛔ not the queue commit's own timestamp.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 15, 2026 02:51
@os-warren
os-warren enabled auto-merge September 15, 2026 02:51
@os-warren
os-warren added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 68fea8b Sep 15, 2026
44 checks passed
@os-warren
os-warren deleted the claude/issue-18122-closed-duration-types branch September 15, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: closed duration types DurationMs / DurationSeconds beside EpochMs — step ① of ruling A on #18115 (declared shape for the unit-in-key census)

2 participants