feat(spec): the ten bare wall-clock instants adopt EpochMs (tranche 1 of ruling C on #18080) - #18631
Conversation
… of ruling C on #18080) Each of the ten instants named by census #18078 now declares its unit through the shared `EpochMs` schema (`src/shared/epoch.zod.ts`) instead of a bare `z.number()`. No key is renamed: `timestamp`, `tokenExpiry`, `deadline` and `validUntil` keep their names — a rename is a separate retirement with its own ADR-0087 entry and is not ruled here. Where a JSDoc already said "(Unix milliseconds)" it stays and the describe is untouched; where nothing named the unit, the describe gains the unit the type now carries. Three of the ten (`kernel/plugin-loading.zod.ts` `timestamp`, `startedAt`, `completedAt`) were NOT bare `z.number()` — they already carried `.int().min(0)`. They adopt `EpochMs.min(0)` so the site keeps the floor it already declared: a bare `EpochMs` would have DROPPED `.min(0)` and widened three published keys, which is the opposite of what this tranche does. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…t EpochMs Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…ochms-instants-tranche1
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 83cc20dcebacd2763d815126c5e1a9dced9fece6 && git checkout 83cc20dcebacd2763d815126c5e1a9dced9fece6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7f4aa9e5c3e39039d48750cd892f9edd1139b810 597bb75a13c6a04a1add15774d42ad60c50a3846 && git checkout -B drift-repro 7f4aa9e5c3e39039d48750cd892f9edd1139b810 && git merge --no-ff 597bb75a13c6a04a1add15774d42ad60c50a3846
node scripts/docs-audit/affected-docs.mjs --json 7f4aa9e5c3e39039d48750cd892f9edd1139b810 |
Fixes #18114
Clause-②: noDerived by the dispatching seat and confirmed by the measurements below: adopting
EpochMsnarrows the accepted set on ten already-published keys (seven of them), adds no exported symbol, no new key, no registry row and no closed-set member; clause ② polices the widening direction.check-widening-tells --declaration noagrees (below). Nothing measured here contradicts it.Tranche 1 of ruling C on #18080 (decision batch #133 item 4). Durations are tranche 2 and are untouched here.
What changed
Each of the ten wall-clock instants named by census #18078 now declares its unit through the shared
EpochMsschema (packages/spec/src/shared/epoch.zod.ts) instead of a barez.number(). No key is renamed:timestamp,tokenExpiry,deadlineandvalidUntilkeep their names. The gate script is untouched — this tranche is data-only.Where a JSDoc already said "(Unix milliseconds)" it stays and the describe is left alone; where nothing named the unit, the describe gains the unit the type now carries.
The population, re-derived on today's
main— three rows are NOT what the card describesThe card calls all ten "bare
z.number()". Re-derived by key + file at the branch point (559041d39d), three are not:data/document.zod.tscreatedAtz.number()EpochMsdata/document.zod.tsexpiresAtz.number().optional()EpochMs.optional()system/supplier-security.zod.tsassessedAtz.number()EpochMssystem/supplier-security.zod.tsvalidUntilz.number()EpochMssystem/supplier-security.zod.tsdeadlinez.number()EpochMsidentity/identity.zod.tsexpiresAtz.number().optional()EpochMs.optional()kernel/plugin-loading.zod.tstimestampz.number().int().min(0)EpochMs.min(0)kernel/plugin-loading.zod.tsstartedAtz.number().int().min(0).optional()EpochMs.min(0).optional()kernel/plugin-loading.zod.tscompletedAtz.number().int().min(0).optional()EpochMs.min(0).optional()shared/connector-auth.zod.tstokenExpiryz.number().optional()EpochMs.optional()The seat's spot-check caught
startedAt;timestampandcompletedAtcarry the same.int().min(0)and were unread by it. This does not refute the row's identity ("an instant not declaredEpochMs"), but it decides how the three are written.Why those three keep
.min(0)— a bareEpochMsthere would have WIDENED three published keysEpochMsisz.number().int()and deliberately carries no.min()(its docblock: "a pre-1970 instant is negative and legitimate, and inventing a floor here would refuse data this schema has no business judging"). The threeplugin-loadingkeys already declared their own floor. Writing them as a bareEpochMswould have dropped.min(0), i.e. newly accepted negative instants on three published keys — a widening, the exact directionClause-②: nodenies. They are therefore writtenEpochMs.min(0), so their accepted set is unchanged and only their description is new. Evidence, from the regenerated JSON Schema:EpochMs's docblock precedent supports this: "the four that already declared.int()keep exactly what they had" — adoption preserves the site's accepted set rather than resetting it. The gate recognises the chain: its root walk bottoms out at theEpochMsidentifier whatever methods sit above it, and all three are reported[instant: EpochMs]in the census below.The seven genuinely bare rows move from
{"type":"number"}to{"type":"integer"}with Zod's safe-integer bounds. That is the ruled narrowing.Measurement 1 — does any first-party producer write a NON-INTEGER into any of the ten keys?
Zero. So this is alignment to a declared convention: no
**BREAKING**banner, no ADR-0087 disposition, andpackages/spec/src/migrations/registry.tsis not touched (the branch the card told me to stop and report on did not fire).Method, not just the verdict:
versionNumber,assessedBy,overallCompliant,requirementId,tokenExpiry,sharedWith) across the repo. Outsidepackages/specthe only hits are generated reference pages and the surface snapshots; the sole first-party producers arepackages/spec's own tests.document.test.ts(1704067200000,Date.now(),1735689600000,1767225600000),supplier-security.test.ts(Date.now(),Date.now() + 365*24*60*60*1000,1704067200000),connector-auth.test.ts(1700000000),plugin-loading.test.ts(Date.now()),identity.test.ts(Date.now() + 3600000). All integers;Date.now()is one by definition.*.ts,tsx,js,mjs,json,yaml,mdxreturns exactly one hit —packages/spec/src/kernel/context.test.ts:90 startedAt: 1.5— which is the refusal pin onKernelContext.startedAt, a key that is ALREADYEpochMs(it('should reject non-integer startedAt')). That is the control: the instrument does find a fractional write when one exists, and it found no producer. A second sweep forperformance.now()/Math.random()into those keys returns nothing.pnpm --filter @objectstack/spec test— 485 files / 13837 tests, all passing. No fixture needed changing.Measurement 2 — the gate's own summary line
api/websocket.zod.tsoccurredAtandlastSeenAt,kernel/context.zod.tsstartedAt,kernel/service-registry.zod.tsregisteredAtandcreatedAt; there is no sixth in the tree, before or after this change. The "6" is a census-side count, not this gate's.Measurement 3 —
tokenExpiryand the A-class row shapeReported as the card asks, and one half of it is NOT MEASURED, deliberately, rather than reported clean:
tokenExpirystops being a[name: -] [prose: -]row" is not a statement this instrument can make.tsx scripts/check-duration-unit-keys.ts --listprinted zero[name: -] [prose: -]rows before the change, sotokenExpirywas not one of them and could not leave. The A-class 23 belongs to census 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, a different instrument with its own classes; this gate only lists keys it ADMITS, and admission needs a unit in the key name, in a closed type, or in the prose.tokenExpiryhad none of the three, so it was absent from the census entirely.tokenExpiryenters this census, admitted by TYPE.[name: -] [prose: -]rows go 0 → 4 —document.createdAt,document.access.expiresAt,supplier-security.assessedAt,.validUntil. Those are the four whose describe deliberately stays unit-free because their JSDoc already says "(Unix milliseconds)", exactly as the card instructs; they are admitted by type and carry[jsdoc: ms].A gate red before green
check:docs— observed red, then green.check:generatedat the implementation commit:✗ 1 of 15 artifact(s) stale: content/docs/references/**, exit 1. Aftergen:docs:✓ All 15 generated artifacts are up to date., exit 0.check:duration-unit-keys— ablated, because it is green both before and after and would otherwise never have been observed failing for this change. One describe on one of the new rows was mutated to name a different unit, proving the gate actually reaches the ten new sites rather than passing them by:Expected direction was "turns red" and that is what was observed. The mutation ran under a
trap ... EXIT INT TERMrestore with absolute paths, the restore is proven by blob-hash identity with theHEADblob (not by an exit code), and no test file was left behind. Nodistpreflight applies: this gate is a TypeScript source AST scan, so the on-disk byte proof above IS the reachability proof.Gates run
95 commands derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(it derives the change set itself from the merge base) were run. 94 exit 0. Named explicitly by the card, all green:check:api-surfacereporting the surface unchanged is the load-bearing one for consumers:z.inputofEpochMsisnumber, so no TypeScript type on any published entry moves and no importing package owes a re-measure.NOT MEASURED (1):
pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET, needs a full repo build (26+ connector packages have nodist/in this worktree). Neither a pass nor a failure. CI builds everything and owns it. Five siblings that first reported the same code (check:doc-formula-expressions,check:doc-security-posture,check:skill-examples,check:docs-transcript-drift,check:lean-entry-closure) were converted into real readings by building@objectstack/lint,@objectstack/formulaand@objectstack/client-react, and all five are green.One red that this diff does not cause:
pnpm check:cross-package-test-inputsexits 1 in this worktree, flagging@objectstack/cli's directory walk frompackages/spec/dist/. Proven unrelated by a two-legged control — see acceptance notes.File surface
Ten paths, and every one of them is either a card row or a member of the pre-declared OPEN set:
Nothing under
packages/spec/src/migrations/**,packages/spec/src/ui/view.zod.ts,packages/spec/scripts/**,packages/spec/liveness/**orcontent/docs/releases/**is touched.packages/spec/scripts/check-duration-unit-keys.tsis untouched: this tranche is data-only.Acceptance notes (out of scope, not filed as part of this PR)
pnpm check:cross-package-test-inputsdecides one of its verdicts from a GITIGNORED directory's presence on disk, not from tracked source. In a worktree wherepackages/spec/dist/exists it exits 1, flagging@objectstack/cli's walk frompackages/spec/dist/as reached by no declared glob; in a checkout of the identical source where it does not exist, it exits 0. Two-legged control, each an unbuilt worktree runningnode scripts/check-cross-package-test-inputs.mjsdirectly: the branch point559041d39dexits 0, and this branch's own tree exits 0 — thenmkdir -p packages/spec/dist/probe-subdir && echo x > .../a.d.tsin that same clean tree flips it to exit 1 with the byte-identical finding. So the verdict is a property of the developer's build state; a contributor who builds spec before running the gate sees a red that a fresh checkout never shows, and the reverse hides it. Dedupe terms:check-cross-package-test-inputs,walked root,packages/spec/dist,init-created-files-summary.e2e.test.ts,ACCEPTED_WALK_RADII,build-state dependent gate verdict.tokenExpirycarries a value that reads as epoch SECONDS.packages/spec/src/shared/connector-auth.test.ts:32writestokenExpiry: 1700000000— November 2023 read as seconds, 20 January 1970 read as milliseconds. It is an integer, so it is not a measurement-1 hit and the schema cannot tell the two apart; it is simply evidence that the ×1000 ambiguity this card closes was live in the tree rather than theoretical. The describe now says milliseconds, so the trap is shut at the contract. Successor: whoever next edits that fixture — this PR deliberately leaves the file untouched because it is outside the claim's declared file surface and changing it would measure nothing.EpochMs's docblock recommends spelling an instant*At, and four of the ten keys do not (timestamp,tokenExpiry,deadline,validUntil). The card rules a rename out: it is a retirement with its own ADR-0087 entry. Successor: named and already scoped by the ruling on spec: the option-1 population is measured — 6 rows, and it cannot reach the ≥7 duration keys that name no unit anywhere. What is the goal? #18080, not a loose end.Generated by Claude Code