spec: closed duration types DurationMs / DurationSeconds beside EpochMs (step 1 of ruling A on #18115) - #18238
Conversation
… 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>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: First review of this PR. Head confirmed via ① Derived judgmentsSize and base. Published surface — precedent verified, no widening. Root entry The refinement — six rows derived, not accepted. Can every row adopt Author state = parsed state. Both exports are No gate behaviour moved — and what the identical output does and does not establish. ② Semver level
③ Boundary flags1. Ratchet 783 → 785 — registration of two new rows, not a weakening. In 2. Published claims — both new numbers true at this head, and the prose beside them true. 3. Test-layer type error — fixed by narrowing, not by a cast or a suppression. 4. The two gates the dev could not measure — measured from CI, not from the report. Other measurements. Local at head, each exit 0: Implemented-by: VERDICT: PASS Generated by Claude Code |
席位采纳达档记录
|
| 段落 | 原文 |
|---|---|
| 维护者速读(维护者作答前读的那段) | 「名字表退休」(3 处 退休) |
| 执行形状段 | 「名字表降为提示」(1 处 提示) |
「退休」与「降为提示」不是一回事,而 #18123(步骤 ②)正是改 admission 规则的那张卡 —— 它必须二选一。⛔ 这不是本席能替维护者裁的。已在 #18123 上落一条,交由它的派发席在起工前解决。
落地前检
- ① 达档 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
Provenance — carriers cleared against the at-tier PASS, and the three landing pre-checks, each with the time it was taken
Record of the clearing: comment
Carriers cleared in one stroke and read back: PR at
⏳ Flipping ready and enqueuing. Landing will be confirmed git-side on Generated by Claude Code |
…tack-ai#18243) Fixes objectstack-ai#18229 ## What changed `scripts/pm/check-half-states.mjs` only. H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` — the wide body-first/branch-fallback delivery relation — as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped, so acting on it hangs the gate on a card nothing will ever close — where the stroke that clears gates never arrives. Three edits: 1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read of `deliveryEvidence`'s existing grading, never a second keyword parser. It answers "does this PR CLOSE #n", which is the question the gate's clearing stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer false. 2. **H31's own population filter** — the carrier comparison runs over the PRs `bindingClosesCard` accepts. Those rows are byte-identical to before. 3. **A DECLINED row for every other binding** — a weak-bound delivering PR whose carrier differs from the card's still produces a row, one that names both carriers and the binding it read, states that it declined to judge, and prescribes no write. A weak-bound PR whose carrier agrees produces nothing, exactly as before. `TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one spelling across H13's exemption list and H31's new clause. ## Why this seam, and how the shared invariant survives The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids it (「⛔ Do not narrow it here to serve H8: that would make the live half invisible to the rows that exist to see it」), and H8's open side, H35's sibling resolver, `claimDelivery`, H53 and the `check-clause2-carriers` pairing all still read it wide. The narrowing is H31's own filter over the population that relation hands it, so the rows still agree about which PR delivers which card and differ only about which binding makes a CARRIER PAIR — a question only H31 asks. Both docblocks that state the invariant were updated to say so rather than left to imply the old thing. Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's own uniform: a split that was never judged renders identically to a board whose two carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」. Hence the declined row, and hence its scope: it speaks only where the carriers actually differ, because a standing row per tracker per sweep is the disease, not the cure. The declined row is deliberately NOT marked `UNJUDGED_MARKER` — that marker buys trim priority ahead of judged rows, and a decline must never sort ahead of a real carrier split in the same `gate` band. ## Measured on the live board Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run 34919049964, carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212 (draft, via a `Part of` declaration). Replayed offline against the 9 open PRs and the 9 open cards those PRs name (the exact H31 candidate set — a card no open PR names can never reach the comparison): | card | `tracking` | delivering evidence | row before | row after | |---|---|---|---|---| | objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none | | objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none | | objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none | | objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none | | objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none | | objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding | **DECLINED row** | | objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none | | objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none | | objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none | One row changes, and it is the expected one. Every other delivering binding on the live board is a closing keyword, so nothing else moves. ## Tests `pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs --self-test`): **4042 cases pass** before, **4075 cases pass** after, exit 0 — 33 new cases. The pin and the control both land, per the card's own remedies: - pin: the `Part of`-only tracker still produces a row (⛔ not a silent skip), the row says `DECLINES to judge`, names the binding it read, explains why a ruling anchor can never clear a gate, and prescribes NOTHING; it is asserted to be neither the old action-shaped sentence nor a LOUD row nor an UNJUDGED-ranked one. - control: a closing-keyword binding whose card lacks the gate — the row STANDS, with the unchanged action-shaped sentence. - plus: agreement on a weak binding stays clean both ways; a weak-bound card without `tracking` still reports the split but without the ruling-anchor clause; an adjudicable split outranks a decline and names only the closing-bound PR. **Ablation** (one-off, restored; run from the committed fix). Mutating `bindingClosesCard` back to the pre-fix wide population (`deliveryEvidence(pr, n) !== null`) turns the self-test RED: ``` == anchor counts BEFORE == old-text occurrences: 1 / new-text occurrences: 0 mutation written == anchor counts AFTER == old-text occurrences: 0 / new-text occurrences: 1 HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 MUT blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75 VERDICT ablation-exit=1 ✗ check-half-states self-test: 11 of 4075 case(s) failed. == restore leg == REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 old-text occurrences after restore: 1 / new-text occurrences after restore: 0 git diff HEAD bytes: 0 ``` The mutation is proved on disk by the blob hash moving off the HEAD blob and by the two anchor occurrence counts flipping; the restore leg is proved by the blob hash returning to the HEAD blob and by an empty `git diff HEAD`, not by an exit code. There is no build step and no `dist/` for a repo-root `.mjs`, so the rebuild half of the ablation preflight does not apply here. **Gates** — derived with no paths, at head `e0a4efa`: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 41 command(s) node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 41 exited 0, each captured by redirect before any pipe. The gate script's own wiring is inside that set: `pnpm check:pm-half-states` is the package.json step lint.yml runs, and it is the script's `--self-test`. **Lint** — a declared narrowing, not the farm. `eslint scripts/pm/check-half-states.mjs --no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0 errors, 0 warnings**, exit 0. The population is read from eslint's own config, not guessed — an `ESLint` instance over `git ls-files` reports `tracked=8674 in-scope=6755 ignored=1919 no-rules=0`. Invariance: this repo runs one `eslint.config.mjs` which enables type-aware linting for no file at all (every `parserOptions` in it is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero `project`/`projectService` hits; the config says so itself at its `QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff — so no rule's verdict on a file this PR did not touch can move because of it. The repo-wide `pnpm lint` run is CI's. `skip-changeset`: `scripts/pm/**` is PM tooling and ships in no package's `files[]`, so nothing published moves. ## Acceptance notes - `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this is in-seat review. `.github/workflows/half-state-patrol.yml` and anchor objectstack-ai#9857 are untouched: the workflow runs the script from `main`, so the fix reaches the anchor at the first scheduled sweep after landing. - objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier that `scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic tracker reached through a `Part of` line. It is out of scope here and remains open. `bindingClosesCard` is exported for it: that file already imports `deliveryEvidence` and `prDeliversCard` from this one, so the fix there is an import rather than a second copy of the predicate. - noted, not filed: the sibling install of this script in `objectui` (`scripts/pm/check-half-states.mjs`) has already drifted from this copy (different md5 at `b3b43b6`), while this file's header describes the pair as copied VERBATIM. No gate can hold two repos equal, so the wording is an aspiration rather than a claim — carrier: the next dev dispatched on a cross-repo patrol card. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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.
Where the types landed, and why a sibling file
packages/spec/src/shared/duration.zod.ts, a new sibling re-exported fromsrc/shared/index.tsexactly asepoch.zodis — not appended toepoch.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 exportINSTANT_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
EpochMsis" means the./sharedsubpath, not the root entry. Four readings on this branch, the last two taken from the regenerated artifact:The
api-surface/shared.jsondelta is 4 rows added, 0 removed —DurationMs (const),DurationMs (type),DurationSeconds (const),DurationSeconds (type). No rootsrc/index.tsre-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:kernel/plugin-lifecycle-advanced.zod.tsshutdownTimeoutz.number().int().min(0).default(30000)kernel/plugin-security-advanced.zod.tscors.maxAgez.number().int().optional()system/metrics.zod.tsslideIntervalz.number().int().positive().optional()system/auth-config.zod.tssession.updateAgez.number().default(60 * 60 * 24)api/contract.zod.tsmeta.durationz.number().optional()data/field-value.zod.tsFileValue.durationz.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 barez.number()rows are tightened by adopting it, which is the same tighteningepoch.zod.tsrecords for the sites that adoptedEpochMs..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)admits0, and a zero timeout means "do not wait".DurationMinutes/DurationHours/DurationDaysare 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*Parsedsynonym, and the isomorphism is pinned as ADR-0122 requires:Iso873/Iso874intype-alias-convention.pin.test.ts(count 783 -> 785, both prose statements moved with it).check:spec-parsed-aliasreads that file as its exemption registry, so the pins are load-bearing rather than decorative.The pin can actually fail — ablation
Dropping
.nonnegative()fromDurationMsonly, proven on disk before the run (the anchored line count went1 -> 0, the mutated spelling-> 1) rather than trusted to the editor's exit code:Restore proven by blob identity, not by an exit code:
git hash-objectreturnedf02731a63112ffbee7f8263fc2fea36ec0dfc27f, equal toHEAD:packages/spec/src/shared/duration.zod.ts, withgit diff HEADempty.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): thesharedrow 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 cleanpnpm linteslint . --no-inline-config— not a narrowingpnpm --filter @objectstack/spec testTest Files 478 passed (478)·Tests 13622 passed (13622)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:generatedAll 15 generated artifacts are up to datepnpm --filter @objectstack/spec check:duration-unit-keyspnpm check:spec-parsed-alias1446 bare z.input aliases, 785 pinned isomorphicpnpm --filter @objectstack/spec check:llms-txt97 claim(s) re-derivedpnpm check:quick-reference-counts13 section(s), every heading matchespnpm check:nul-bytesThe 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 arecheck:dual-build-cjs-loadsandcheck:type-check-debt: both refused withPREREQUISITE NOT METbecause they read every workspace package'sdist/, which needsturbo run build --filter='./packages/*' --filter='./packages/*/*'— a whole-farm build this diff cannot make relevant, since it touches onlypackages/spec. Five further gates that first refused the same way were converted to real green readings by building@objectstack/formula,@objectstack/lintand@objectstack/client-react.This step changes no gate behaviour, and the gate says so itself:
check:duration-unit-keysreports the same 211 duration-shaped keys, 6EpochMsinstants and 11externalVocabularymirrors as onorigin/main. It recognises exactly one identifier root today —EpochMs— so a key typedDurationMsis outside its population rather than exempted by it, which is precisely what step ② changes.Changeset
minor, notpatch:Clause-②: yesand the./sharedentry gains exported symbols. Notmajor— breaking changes ship asminoruntil 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
packages/spec/src/data/driver.zod.ts:414idlesits in the unitless census but is a pool-stat count ({ total, idle, active, waiting }fromgetPoolStats), not a duration — worth knowing for step ③'s triage, since its name reads like a duration and its neighbours in the census are. 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, which reads this same census.packages/spec/src/shared/connector-auth.zod.ts:38tokenExpirydescribes itself as a "Token expiry timestamp" — an instant, soEpochMsrather than a duration type. Same 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.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