Skip to content

feat(spec): the ten bare wall-clock instants adopt EpochMs (tranche 1 of ruling C on #18080) - #18631

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-18114-epochms-instants-tranche1
Sep 17, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-18114-epochms-instants-tranche1

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18114

Clause-②: no

Derived by the dispatching seat and confirmed by the measurements below: adopting EpochMs narrows 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 no agrees (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 EpochMs schema (packages/spec/src/shared/epoch.zod.ts) instead of a bare z.number(). No key is renamed: timestamp, tokenExpiry, deadline and validUntil keep 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 describes

The card calls all ten "bare z.number()". Re-derived by key + file at the branch point (559041d39d), three are not:

file key shape BEFORE (re-derived) shape AFTER unit was stated
data/document.zod.ts createdAt z.number() EpochMs JSDoc
data/document.zod.ts expiresAt z.number().optional() EpochMs.optional() JSDoc
system/supplier-security.zod.ts assessedAt z.number() EpochMs JSDoc
system/supplier-security.zod.ts validUntil z.number() EpochMs JSDoc
system/supplier-security.zod.ts deadline z.number() EpochMs nowhere — describe gains it
identity/identity.zod.ts expiresAt z.number().optional() EpochMs.optional() describe named the epoch only — describe gains the unit
kernel/plugin-loading.zod.ts timestamp ⚠️ z.number().int().min(0) EpochMs.min(0) nowhere — describe gains it
kernel/plugin-loading.zod.ts startedAt ⚠️ z.number().int().min(0).optional() EpochMs.min(0).optional() nowhere — describe gains it
kernel/plugin-loading.zod.ts completedAt ⚠️ z.number().int().min(0).optional() EpochMs.min(0).optional() nowhere — describe gains it
shared/connector-auth.zod.ts tokenExpiry z.number().optional() EpochMs.optional() nowhere — describe gains it

The seat's spot-check caught startedAt; timestamp and completedAt carry the same .int().min(0) and were unread by it. This does not refute the row's identity ("an instant not declared EpochMs"), but it decides how the three are written.

Why those three keep .min(0) — a bare EpochMs there would have WIDENED three published keys

EpochMs is z.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 three plugin-loading keys already declared their own floor. Writing them as a bare EpochMs would have dropped .min(0), i.e. newly accepted negative instants on three published keys — a widening, the exact direction Clause-②: no denies. They are therefore written EpochMs.min(0), so their accepted set is unchanged and only their description is new. Evidence, from the regenerated JSON Schema:

PluginLoadingEvent.timestamp  {"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Event timestamp — Unix milliseconds"}
PluginLoadingState.startedAt  {"description":"Loading start time — Unix milliseconds","type":"integer","minimum":0,"maximum":9007199254740991}
DocumentVersion.createdAt     {"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Creation timestamp"}

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 the EpochMs identifier 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, and packages/spec/src/migrations/registry.ts is not touched (the branch the card told me to stop and report on did not fire).

Method, not just the verdict:

  1. Who can produce these shapes at all. Grepped the eight enclosing schema names and their distinctive sibling keys (versionNumber, assessedBy, overallCompliant, requirementId, tokenExpiry, sharedWith) across the repo. Outside packages/spec the only hits are generated reference pages and the surface snapshots; the sole first-party producers are packages/spec's own tests.
  2. Every value those producers write, read by hand: 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.
  3. Repo-wide fractional sweep with a positive control. A regex for a fractional literal on any of the nine key names across *.ts,tsx,js,mjs,json,yaml,mdx returns exactly one hit — packages/spec/src/kernel/context.test.ts:90 startedAt: 1.5 — which is the refusal pin on KernelContext.startedAt, a key that is ALREADY EpochMs (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 for performance.now() / Math.random() into those keys returns nothing.
  4. The suites, post-merge: pnpm --filter @objectstack/spec test — 485 files / 13837 tests, all passing. No fixture needed changing.

Measurement 2 — the gate's own summary line

BEFORE (at 559041d39d):  5 declared `EpochMs` instant(s)   /  185 unit-declaring numeric key(s)
AFTER  (at 597bb75a13): 15 declared `EpochMs` instant(s)   /  195 unit-declaring numeric key(s)

⚠️ The card's acceptance reading is off by one at the base. It predicts 6 before and 16 after; the instrument reads 5 before and 15 after. The delta is exactly +10, as ruled — only the base was wrong. The five pre-existing adopters are api/websocket.zod.ts occurredAt and lastSeenAt, kernel/context.zod.ts startedAt, kernel/service-registry.zod.ts registeredAt and createdAt; 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 — tokenExpiry and the A-class row shape

Reported as the card asks, and one half of it is NOT MEASURED, deliberately, rather than reported clean:

BEFORE: (no row — the key is not in --list at all)
AFTER:  packages/spec/src/shared/connector-auth.zod.ts:39  tokenExpiry  [name: -] [prose: ms] [instant: EpochMs]  "Token expiry timestamp (Unix milliseconds)"
  • And the row shape the card named does move, in the other direction: [name: -] [prose: -] rows go 0 → 4document.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:generated at the implementation commit: ✗ 1 of 15 artifact(s) stale: content/docs/references/**, exit 1. After gen: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:

anchor counts BEFORE mutation: orig=1 mut=0      on-disk blob 747bb855f3e0…  (== HEAD blob)
anchor counts AFTER  mutation: orig=0 mut=1      on-disk blob a1723e069fea…  (≠ HEAD blob)

RUN A (mutated)  EXIT=1
  ✗ check:duration-unit-keys — 1 offender(s) among 195 unit-declaring numeric key(s)
  [instant-unit-contradicts-schema] packages/spec/src/shared/connector-auth.zod.ts:39 `tokenExpiry`
    — typed `EpochMs` (epoch MILLISECONDS) but the describe says seconds.

restore: git checkout HEAD -- PATH;  on-disk blob 747bb855f3e0… (== HEAD blob);  git diff HEAD empty
anchor counts AFTER restore: orig=1 mut=0

RUN B (restored) EXIT=0  — 15 declared `EpochMs` instant(s); zero offenders, no baseline.

Expected direction was "turns red" and that is what was observed. The mutation ran under a trap ... EXIT INT TERM restore with absolute paths, the restore is proven by blob-hash identity with the HEAD blob (not by an exit code), and no test file was left behind. No dist preflight 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:

pnpm --filter @objectstack/spec check:generated          ✓ All 15 generated artifacts are up to date.
pnpm --filter @objectstack/spec check:authorable-surface ✓ 1538 schemas generated
pnpm --filter @objectstack/spec check:api-surface        ✓ public API surface + factory signatures unchanged
pnpm --filter @objectstack/spec check:docs               ✓ 224 generated files in sync with packages/spec
pnpm --filter @objectstack/spec typecheck                ✓ (incl. check:scripts-typecheck, check:test-typecheck)
pnpm --filter @objectstack/spec test                     ✓ 485 files / 13837 tests
node scripts/pm/check-widening-tells.mjs --declaration no ✓ 10 file(s) — 5 judged against a declared surface
                                                           (no widening tell), 5 NOT MEASURED (generated docs,
                                                           no declared surface covers them)
pnpm lint  (eslint . --no-inline-config, whole repo)     ✓ 6809 files judged, 0 errors, 0 warnings, at 597bb75a13

check:api-surface reporting the surface unchanged is the load-bearing one for consumers: z.input of EpochMs is number, 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 no dist/ 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/formula and @objectstack/client-react, and all five are green.

One red that this diff does not cause: pnpm check:cross-package-test-inputs exits 1 in this worktree, flagging @objectstack/cli's directory walk from packages/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:

packages/spec/src/data/document.zod.ts               2 rows + the EpochMs import
packages/spec/src/system/supplier-security.zod.ts    3 rows + the import
packages/spec/src/identity/identity.zod.ts           1 row  + the import
packages/spec/src/kernel/plugin-loading.zod.ts       3 rows + the import
packages/spec/src/shared/connector-auth.zod.ts       1 row  + the import
content/docs/references/data/document.mdx            OPEN set — gen:docs, gate-reverse-required
content/docs/references/identity/identity.mdx        OPEN set — gen:docs
content/docs/references/integration/connector.mdx    OPEN set — gen:docs
content/docs/references/kernel/plugin-loading.mdx    OPEN set — gen:docs
content/docs/references/system/supplier-security.mdx OPEN set — gen:docs
.changeset/18114-epochms-instants-tranche1.md        OPEN set — pre-declared in the claim

Nothing under packages/spec/src/migrations/**, packages/spec/src/ui/view.zod.ts, packages/spec/scripts/**, packages/spec/liveness/** or content/docs/releases/** is touched. packages/spec/scripts/check-duration-unit-keys.ts is untouched: this tranche is data-only.

Acceptance notes (out of scope, not filed as part of this PR)

  1. To file — pnpm check:cross-package-test-inputs decides one of its verdicts from a GITIGNORED directory's presence on disk, not from tracked source. In a worktree where packages/spec/dist/ exists it exits 1, flagging @objectstack/cli's walk from packages/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 running node scripts/check-cross-package-test-inputs.mjs directly: the branch point 559041d39d exits 0, and this branch's own tree exits 0 — then mkdir -p packages/spec/dist/probe-subdir && echo x > .../a.d.ts in 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.
  2. Noted, not filed — the only first-party fixture for tokenExpiry carries a value that reads as epoch SECONDS. packages/spec/src/shared/connector-auth.test.ts:32 writes tokenExpiry: 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.
  3. Noted, not filed — 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

… 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>
@os-bill os-bill added domain:spec priority:p2 Medium: important, M3 labels Sep 17, 2026 — with Claude
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data protocol:system tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 7f4aa9e5c3e39039d48750cd892f9edd1139b810packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83cc20dcebacd2763d815126c5e1a9dced9fece6 — the merge of head 597bb75a13c6a04a1add15774d42ad60c50a3846 into base 7f4aa9e5c3e39039d48750cd892f9edd1139b810, 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 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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants