Repository navigation
Commit 25c9a83
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 559041d): 5 declared `EpochMs` instant(s) / 185 unit-declaring numeric key(s)
AFTER (at 597bb75): 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:
- **NOT MEASURED — "`tokenExpiry` stops being a `[name: -] [prose: -]`
row" is not a statement this instrument can make.** `tsx
scripts/check-duration-unit-keys.ts --list` printed **zero** `[name: -]
[prose: -]` rows before the change, so `tokenExpiry` was not one of them
and could not leave. The A-class 23 belongs to 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. `tokenExpiry` had none of the three, so it was absent from
the census entirely.
- **Measured instead, the real transition:** `tokenExpiry` **enters**
this census, admitted by TYPE.
```
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 → 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: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 747bb85… (== 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 747bb85… (== 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 597bb75
```
`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 #18080, not a loose end.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 681ebe4 commit 25c9a83
11 files changed
Lines changed: 48 additions & 24 deletions
File tree
- .changeset
- content/docs/references
- data
- identity
- integration
- kernel
- system
- packages/spec/src
- data
- identity
- kernel
- system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| |||
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
116 | | - | |
| 116 | + | |
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | | - | |
| 207 | + | |
208 | 208 | | |
209 | 209 | | |
210 | 210 | | |
| |||
688 | 688 | | |
689 | 689 | | |
690 | 690 | | |
691 | | - | |
| 691 | + | |
692 | 692 | | |
693 | 693 | | |
694 | 694 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
81 | | - | |
82 | | - | |
| 81 | + | |
| 82 | + | |
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
70 | | - | |
| 69 | + | |
| 70 | + | |
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
| 76 | + | |
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
35 | | - | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
399 | 400 | | |
400 | 401 | | |
401 | 402 | | |
402 | | - | |
| 403 | + | |
403 | 404 | | |
404 | 405 | | |
405 | 406 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
108 | 109 | | |
109 | 110 | | |
110 | 111 | | |
111 | | - | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
96 | 97 | | |
97 | 98 | | |
98 | 99 | | |
99 | | - | |
| 100 | + | |
100 | 101 | | |
101 | 102 | | |
102 | 103 | | |
| |||
151 | 152 | | |
152 | 153 | | |
153 | 154 | | |
154 | | - | |
| 155 | + | |
155 | 156 | | |
156 | 157 | | |
157 | 158 | | |
158 | 159 | | |
159 | | - | |
| 160 | + | |
160 | 161 | | |
161 | 162 | | |
162 | 163 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
35 | 36 | | |
36 | 37 | | |
37 | 38 | | |
38 | | - | |
| 39 | + | |
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
| |||
0 commit comments