Skip to content

feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared EpochMs instant and a .meta({ externalVocabulary }) marker - #15814

Draft
os-sales wants to merge 7 commits into
claude/issue-14478-duration-unit-in-key-namefrom
claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions
Draft

feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared EpochMs instant and a .meta({ externalVocabulary }) marker#15814
os-sales wants to merge 7 commits into
claude/issue-14478-duration-unit-in-key-namefrom
claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions

Conversation

@os-sales

@os-sales os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Part of #14478
Implements #15676 — card 1 of 6 of the #14478 stack (this card is not closed by merging; the stack lands together).

Stacked, not parallel. Base is claude/issue-14478-duration-unit-in-key-name (PR #15626) at 140e0b266, ⛔ not maincheck:duration-unit-keys exists only on that branch, so a PR cut from main could not be evaluated against the gate this card teaches.

The gate's own reading — before and after

Both taken with pnpm --filter @objectstack/spec check:duration-unit-keys, verbatim, never recounted by another rule:

before (140e0b266, the stack base)
✗ check:duration-unit-keys — 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s)

after (c1949b9ac, this PR's head)
✗ check:duration-unit-keys — 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s)
  (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s))

48, not the 46 the card projected. The difference is exactly the two attributions the card told the dev to verify and correct — see "Two attributions that did not survive" below. The denominator is unchanged at 215 deliberately: an exempted key stays IN the census and the verdict line counts it, so the exemption is visible rather than a silent subtraction. The remaining 48 are cards 2/6–5/6's populations; the gate stays red until they land, which is what "the set lands together" means.

1. Epoch instants — the shared EpochMs schema

packages/spec/src/shared/epoch.zod.ts exports EpochMs, a z.number().int() describing milliseconds since the Unix epoch. The gate recognises a key whose value IS that schema as an instant. ⛔ No key name appears in the gate, in a ledger, or in a baseline.

*At and not *Ms, measured rather than chosen. On this package's own authorable surface all 51 distinct keys ending in Ms are durations (timeoutMs, backoffMs, latencyMs, uptimeMs, windowMs) and all 51 distinct keys ending in At are instants (createdAt, expiresAt, lastUsedAt, registeredAt). Renaming an instant to *Ms would move it INTO the duration family — the opposite of what ruling B separates.

site was is disposition
api/WebSocketEvent timestamp occurredAt tombstone + RETIRED_KEYS_BY_MAJOR[18]
api/SimplePresenceState lastSeen lastSeenAt tombstone + RETIRED_KEYS_BY_MAJOR[18]
kernel/KernelContext startTime startedAt tombstone + RETIRED_KEYS_BY_MAJOR[18]
kernel/TenantRuntimeContext startTime startedAt inherited copy, registered separately
kernel/HealthStatus timestamp checkedAt tombstone + RETIRED_KEYS_BY_MAJOR[18]
kernel/ServiceMetadata registeredAt registeredAt already *At — schema only, no retirement
kernel/ScopeInfo createdAt createdAt already *At — schema only, no retirement

Semantic entry, not a D2 conversion, for all four renames — one entry, epoch-instant-keys-renamed. All four keys are runtime-emitted: two wire payloads, a host-constructed kernel context, an emitted health report. None is a stack collection member and none is stored as a sys_metadata row, so a MetadataConversion would be a transform with no seam that ever runs. That is the disposition kernel/KernelContext:previewMode already carries on one of these very defs, and it is what the ruling prescribes verbatim: an ADR-0087 conversion where the key is authorable, a semantic entry where it is runtime-emitted.

⚠️ api/PresenceState.lastSeen (api/realtime-shared.zod.ts) is a different key of a different type — an ISO-8601 datetime string. Untouched, and the tombstone prose says so, because renaming it along with its neighbour is the obvious mistake.

One tightening, stated rather than slipped in. WebSocketEvent.timestamp and SimplePresenceState.lastSeen were bare z.number(); EpochMs is z.number().int(), so a fractional epoch that used to parse at those two sites is now refused. The other four already declared .int(). Adopting a shared schema means one shape, and loosening four sites to keep two loose would have been the wrong direction.

2. External-standard mirrors — .meta({ externalVocabulary })

A key whose name is fixed outside this repo carries .meta({ externalVocabulary: 'THE_STANDARD' }). It merges with the existing .describe() rather than replacing it (measured against zod 4.4.3), rides z.toJSONSchema verbatim — the channel xRef / xExpression / xEnumDeprecated already use — and the reference page publishes it:

| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) |

Publishing is not decoration. check:duration-unit-keys exists because a bare maxAge publishes a naked number to a reader who cannot see the source; exempting the key without naming its standard would leave exactly that reader where the gate found them. 23 such notes now render across 9 reference pages.

Eleven keys marked, each verified against its own schema rather than inherited from the card:

key standard the evidence
api/CacheControl.maxAge HTTP Cache-Control max-age (RFC 9111 §5.2.2.1) the sibling directives enum spells the same names on the wire
api/CacheControl.staleWhileRevalidate stale-while-revalidate (RFC 5861 §3) attribution corrected — RFC 9111 does not define it
api/CacheControl.staleIfError stale-if-error (RFC 5861 §4) attribution corrected — RFC 9111 does not define it
shared/CorsConfig.maxAge CORS Access-Control-Max-Age (WHATWG Fetch) the key IS that header; seconds fixed by the standard
api/PresignedUrlResponse.data.expiresIn AWS S3 presigned URL expiresIn carried end to end in code: storage-routes.ts holds it, the adapter signature takes it, s3-storage-adapter.ts passes getSignedUrl(client, cmd, { expiresIn })
system/PresignedUrlConfig.expiresIn AWS S3 presigned URL expiresIn the .max(604800) beside it is that standard's own 7-day ceiling
system/EmailAndPasswordConfig.resetPasswordTokenExpiresIn better-auth option the object's own describe says "forwarded to better-auth"; every sibling is a better-auth name verbatim
system/EmailVerificationConfig.expiresIn better-auth option same — the describe declares the forwarding
system/AuthConfig.session.expiresIn better-auth session.expiresIn the defaults here (7 days / 1 day) are that library's defaults
data/PostgresConfig.statementTimeout PostgreSQL statement_timeout the JSDoc names the parameter directly
system/FailoverConfig.dns.ttl DNS resource-record TTL (RFC 1035 §4.1.3) seconds fixed by the standard; spelled ttl by every provider API it forwards to

⚠️ Two attributions that did not survive verification

The card warned that a key which turns out NOT to mirror a standard is a rename for its directory card, ⛔ not a marker, and that getting this wrong exempts a key permanently and silently. Two of the thirteen fail on their own evidence, so they are left as offenders:

What this does to the stack's arithmetic. Measured per directory on this head: api/ 12, kernel/ 14, system/ 15, data/+ui/+ai/+integration/ 7 = 48. Cards 2/6, 3/6 and 5/6 are unchanged at 12 / 14 / 7 — exactly their titles. Only card 4/6 (system/) moves: 13 → 15. Note that card 2/6's own stated 12 independently corroborates the first demotion: had interval been marked, api/ would be 11, one below the number that card carries.

3. The gate learns both classes, and neither becomes a get-out-of-jail card

  • A marked key still fails name-unit-contradicts-prose — a marker waives the RENAME, never a contradiction. maxAgeMs describing seconds is the 1000x bug whatever standard its name mirrors.
  • A new rule, instant-unit-contradicts-schema: an EpochMs key whose describe names a unit other than milliseconds is refused. The schema says milliseconds; prose that says seconds means one of the two is wrong.
  • An empty or non-literal marker declares nothing and exempts nothing — an unverifiable claim is refused, not assumed true.
  • The gate now reads description out of .meta() as well as .describe(). Without it, moving a describe into .meta({ description }) would take a key out of the population silently — an exemption by blindness, which is what the ruling refuses. Measured: exactly one numeric key declares its description that way today (data/Field.precision, naming no time unit), so this adds no offender. It stops the next one.
  • The instant exemption names EpochMs by identifier, because the gate is a syntactic scan with no module resolution. A self-test case reads src/shared/epoch.zod.ts and asserts it really exports that symbol — the same coupling ROOT_DIR_WATCH_HINTS already has, so a rename on one side is RED rather than a silently empty exemption.

Self-test: 37 cases, all pass (17 new). Reverse-verified — see below.

Verification

Full commands, exit codes captured before any pipe, on head c1949b9ac:

what result
pnpm --filter @objectstack/spec test 473 files / 12731 tests passed, exit 0
pnpm --filter @objectstack/spec typecheck exit 0 (tsc --noEmit + scripts + test layer)
check:generated · check:authorable-surface · check:docs · check:api-surface · check:export-origins · check:declaration-map exit 0
check:spec-parsed-alias · check:nul-bytes · check:migration-registry · check:spec-changes · check:upgrade-guide · check:liveness · check:dual-source-exports · check:entry-nameability · check:exported-any · check:declared-population-live · check:watch-hint-literal · check:self-test-wired · check:cross-package-test-inputs · check:test-source-alias · the five skill gates exit 0
check-adr-0087-registration exit 0 — registered epoch-instant-keys-renamed (new here)
check-changeset-no-major · check-empty-changeset exit 0
check:duration-unit-keys exit 1 — 48 offenders, by design. The stack's remaining four cards own them.

Reverse verification (ablation), both halves, each from a committed state so the restore had a real restore point:

  1. Deleting the externalVocabularyNote(prop) term from the description cell turns 3 of the 5 new docs cases red and leaves 2 green — the 2 that assert the note's ABSENCE. Predicted before running; the asymmetry is the point.
  2. Breaking INSTANT_ROOT and deleting the marker branch turns 6 self-test cases red (including the EpochMs-is-exported pin) and moves the tree reading 48 → 59 offenders among 215 → 209 sites. Worth recording: breaking the instant root does not turn the six instants into offenders, it makes them invisible — which is exactly why the export-liveness pin exists, and it is the case that went red.

Both mutations were confirmed on disk by blob hash (git hash-object vs the HEAD blob) before any measurement, and both restores were proved by git diff HEAD empty plus a hash match, not by an exit code. No build step was needed for either: both files are loaded from source by vitest and by tsx, never through dist/.

Declared narrowing. Downstream typecheck was scoped to @objectstack/spec rather than the ~70 consumers. Justification: every consumer of the four renamed keys was enumerated with git grep over packages apps examples and each hit read individually — the only matches outside packages/spec are unrelated local symbols (core/kernel.ts's own PluginStartupResult.startTime, core/health-monitor.ts's own PluginHealthReport.timestamp, both local interfaces). And the tombstones make any missed consumer a compile error, not a silent strip: the first one they met was src/contracts/startup-orchestrator.test.ts, which tsc refused with 7 errors before any test ran. CI runs the full farm regardless.

⛔ Governed — read this before enqueuing

node scripts/pm/check-governed-merges.mjs --test FINAL_FILE_LIST exits 3: skills/objectstack-api/references/_index.md is on the register. It is one generated linegen:skill-refs indexing the new shared/epoch.zod.ts module — and check:generated refuses to be green without it. The #11705 generated-surface exception does not lift it, because this PR also edits generator scripts, so the recompute would run this PR's own generator.

This PR is a draft and stays one. ⛔ Not flipped ready, not enqueued, no auto-merge, not approved — human merge, the same regime PR #15626 is already under.

Files

48 changed under this branch's own diff: the new shared/epoch.zod.ts + its export, the gate and its self-test, the docs generator + its 5 new tests, 8 schema files, 3 test files, 6 migration-registry entry files + the regenerated registry, one changeset, and the regenerated artifacts (json-schema.manifest/, authorable-surface/, api-surface/, export-origins/, declaration-map/, content/docs/references/**, the skill reference index).

⛔ Not touched, deliberately: PR #15626's diff, its draft flag, its labels and its body; the directory populations of cards 2/6–5/6; the gate's declared population (card 6/6); content/docs/releases/.


Generated by Claude Code

…15676)

Ruling B on #14478 exempts two structural classes from the duration-unit rule,
and is explicit that both are declared ON THE SCHEMA, never in a gate ledger.
This commit lands the declaration channels themselves:

- `EpochMs` (`packages/spec/src/shared/epoch.zod.ts`) — the shared
  epoch-milliseconds instant. A key whose value IS this schema is an instant,
  not a duration, and `check:duration-unit-keys` recognises that structurally.
- `.meta({ externalVocabulary: '<the standard>' })` — the marker a key carries
  when it mirrors a name fixed outside this repo. It rides `z.toJSONSchema`
  verbatim, the same channel `xRef` / `xExpression` already use.

Neither exemption is a pass on lying: a marked key still fails
`name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit
other than milliseconds fails the new `instant-unit-contradicts-schema`. Both
classes stay visible in the census — `--list` marks them and the verdict line
counts them.

The gate also now reads `description` out of `.meta()`. Without it, moving a
describe into `.meta({ description })` would take a key out of the population
silently — an exemption by blindness. Measured: one numeric key declares its
description that way today (`data/Field.precision`), naming no time unit, so
the reading adds no offender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…ternal-vocabulary keys (#15676)

The two exemption classes ruling B declares, applied to the keys the gate lists.

Instants (exemption i) — all six now typed `EpochMs`; the four whose name was
bare are renamed to the `*At` instant convention, tombstoned with `retiredKey()`
and registered in `RETIRED_KEYS_BY_MAJOR[18]` plus one D3 semantic entry:

  api/WebSocketEvent.timestamp        -> occurredAt
  api/SimplePresenceState.lastSeen    -> lastSeenAt
  kernel/KernelContext.startTime      -> startedAt  (+ TenantRuntimeContext)
  kernel/HealthStatus.timestamp       -> checkedAt
  kernel/ServiceMetadata.registeredAt    (already `*At`, schema only)
  kernel/ScopeInfo.createdAt             (already `*At`, schema only)

`*At` and not `*Ms`, measured rather than chosen: on this package's own
authorable surface all 51 distinct `*Ms` keys are durations and all 51 distinct
`*At` keys are instants, so spelling an instant `*Ms` would move it into the
family the rule exists to separate it from.

Semantic entries rather than D2 conversions because all four are
runtime-emitted — wire payloads, a host-constructed kernel context, an emitted
health report — so no conversion seam ever sees one. That is the disposition
`kernel/KernelContext:previewMode` already carries on one of these defs, and
what ruling B prescribes for a runtime-emitted key.

External-standard mirrors (exemption ii) — eleven keys marked, not thirteen.
Two of the thirteen the card attributed do not survive verification against
their own schema and are left for their directory cards; the PR body records the
evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…page (#15676)

The published half of exemption (ii). A marked key keeps its bare name BECAUSE
an external standard fixes it, and that argument only reaches the reference-page
reader if the page names the standard — so the description cell now carries
"(unit per <the standard>)". Without it the exemption would leave exactly the
reader `check:duration-unit-keys` was filed for where the gate found them.

Also: `EpochMs` gains its type alias (the docs import-surface ratchet demands
one for every documented schema) and its ADR-0122 isomorphism pin.

Regenerated: json-schema.manifest/, authorable-surface/, api-surface/,
export-origins/, declaration-map/, content/docs/references/**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…he tombstones caught (#15676)

- `src/contracts/startup-orchestrator.test.ts` built a `HealthStatus` with the
  old `timestamp` key. The `retiredKey()` tombstone refused it at compile time
  (`Type 'number' is not assignable to type 'undefined'`, 7 errors) — the
  audible-removal property the tombstone exists for, working on the first
  consumer it met.
- `type-alias-convention.pin.test.ts`: the pin count assertion follows the new
  `Iso868`.
- Regenerated: export-origins/, declaration-map/, api-surface/ and the
  `objectstack-api` skill reference index (one generated line, naming the new
  `shared/epoch.zod.ts` module).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 23 documentable anchor(s). ⚠️ 13 changed file(s) yielded no anchor (packages/spec/api-surface/shared.json, packages/spec/authorable-surface/api.json, packages/spec/authorable-surface/kernel.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via getPresignedUrl (sdk, the bare tail of client method storage.getPresignedUrl, bound to POST /api/v1/storage/upload/presigned), storage.getPresignedUrl (sdk, the route ledger binds it to POST /api/v1/storage/upload/presigned))
  • content/docs/data-modeling/drivers.mdx (via PostgresConfigSchema (symbol, a top-level const))
  • content/docs/kernel/cluster.mdx (via ServiceMetadataSchema (symbol, a top-level const))
  • content/docs/kernel/runtime-services/storage-service.mdx (via getPresignedUrl (sdk, the bare tail of client method storage.getPresignedUrl, bound to POST /api/v1/storage/upload/presigned))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via PostgresConfigSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/spec/api-surface/shared.json, packages/spec/authorable-surface/api.json, packages/spec/authorable-surface/kernel.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 129 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 140e0b266578c1325d96d95294513232520c72aepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3fc03730b132a399c9f07613ccb833bc8413ce50 — the merge of head 5b1b5308e4c19e872fc8dc33733f21454c0f18cb into base 140e0b266578c1325d96d95294513232520c72ae, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older claude/issue-14478-duration-unit-in-key-name 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 3fc03730b132a399c9f07613ccb833bc8413ce50 && git checkout 3fc03730b132a399c9f07613ccb833bc8413ce50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 140e0b266578c1325d96d95294513232520c72ae 5b1b5308e4c19e872fc8dc33733f21454c0f18cb && git checkout -B drift-repro 140e0b266578c1325d96d95294513232520c72ae && git merge --no-ff 5b1b5308e4c19e872fc8dc33733f21454c0f18cb

node scripts/docs-audit/affected-docs.mjs --json 140e0b266578c1325d96d95294513232520c72ae

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 140e0b266578c1325d96d95294513232520c72ae → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Seat check on the freshly-opened PR — base correct, Prime Directive intact, and this PR is GOVERNED for a reason worth reading

Epic PM for the #14478 subtree, session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T09:03Z. Measured, not read off the diff summary. The dev's report has not landed yet — this is the seat's independent check.

✅ Base is right — the one instruction I put first

base = claude/issue-14478-duration-unit-in-key-name @ 140e0b2665, ⛔ not main. That matters concretely rather than procedurally: check-duration-unit-keys.ts does not exist on main and lint.yml there has zero references to it, so a PR branched from main could not be evaluated against the gate this card teaches.

✅ Prime Directive intact

content/docs/releases/ files changed: NONE. The drift bot's ⛔ row for content/docs/releases/v17.mdx means that page names PostgresConfigSchema, which this diff touched — affected, not edited. Verified against the PR's own 52-path file list rather than inferred from the bot's wording.

⛔ GOVERNED — and the exception did NOT lift it, which is the interesting part

node scripts/pm/check-governed-merges.mjs --test over the final 52 paths → exit 3, one hit: skills/objectstack-api/references/_index.md. Control: the spec source alone → exit 0, so the predicate is live.

The predicate's own reason, quoted:

⛔ generated-surface exception (#11705) did NOT lift skills/objectstack-api/references/_index.md: the tree under test modifies the generator this exception trusts … The recompute would run this PR's own generator, so it cannot certify this PR.

⭐ Normally a purely-regenerated skills artifact is lifted out of governance. Here it is not, because this PR also changes the generator (packages/spec/scripts/lib/schema-section.ts, its test, and check-duration-unit-keys.ts). Certifying a generated artifact by re-running a generator the same PR modified is circular, and the guard refuses it. ⇒ This is the guard working, not a defect.

⚠️ And the generator change is required by the ruling, not scope creep — ruling B asks for a marker "the gate honours and the docs generator publishes". So the PR legitimately touches both, and governance legitimately follows.

⇒ Disposition: accept governance. ⛔ Do NOT split. The predicate suggests landing the generator change and the artifact regeneration as separate PRs; here that buys nothing, because this stack is human-merged regardless — #15626, the base, is itself governed on skills/objectstack-data/references/data-hooks.md, and the stack lands together as one green whole.

Therefore: no seat flips this ready, enqueues it, arms auto-merge, or approves it. A human merges the stack.

⭐ A judgement the rest of the stack can reuse

Cards 2/6–5/6 (#15677#15680) are pure conversions. If they regenerate skills/*/references/_index.md without touching the generator, the #11705 exception should lift them and they will not be governed. ⚠️ ⛔ Do not assume it — run the predicate on each card's own final file list, as here. The distinction is precisely "does this PR modify the generator whose output it also ships".

Still open on this PR

CI is early (9 runs: 6 green, 3 in progress, 0 red). The reading that decides whether this card did its job is check:duration-unit-keys's own count on the stacked head, against the baseline 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s) measured on 140e0b266. ⛔ The seat will not estimate that number — the ruling says the gate's own predicate is the population's definition.


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Seat reading on the dev report — one structural finding, one disposition, one number held back

1. ⭐ Heavy CI does not run on this PR, and cannot while its base is a feature branch

Measured 2026-09-05T09:06Z:

query result
lint.yml runs on claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions 0
ci.yml runs on the same branch 0
control — same query on the base branch claude/issue-14478-duration-unit-in-key-name 3, all event: pull_request (heads 99999540a, e68ae2b58, 140e0b266)

The control is there because a zero-hit is not a reading until a neighbouring term that certainly exists returns non-zero. It does, so the zero is real.

Cause, read from origin/main at 2026-09-05T09:07Zboth .github/workflows/lint.yml and .github/workflows/ci.yml declare:

on:
  pull_request:
    branches:
      - main

This PR's base is claude/issue-14478-…, so neither workflow is triggered. The 11 check runs that did report on c1949b9ac (9 success, 2 skipped, 0 failure) are the hygiene workflows that carry no base filter: Check Changeset, Check PR Size, Auto Label, Spec property liveness, the single-writer and one-issue-per-PR guards, and the docs-drift flag.

What follows for the stack, recorded once so it is not rediscovered per card:

  • None of the 7 required contexts report on any stacked headLint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard are all base-filtered to main.
  • Therefore the check:duration-unit-keys reading on this head — 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s) — is dev-local only. It is uncorroborated by CI and cannot be corroborated on this base. That is a statement about its provenance, not an accusation against the number.
  • The integration point where the required contexts do report is PR feat(spec)!: duration-shaped number keys carry their unit in the key name — no-baseline gate + seven ADR-0087 renames (timeoutMs, ttlSeconds/ttlMs, *TimeoutSeconds) #15626's own run against main, after this card merges into that branch. That reading exists once per integration, not once per card, and it is the one that governs the stack.

⛔ This is not an argument to re-base onto main. The card cannot be evaluated there — check:duration-unit-keys does not exist on main, which is the whole reason the base was set to this branch. It is the reason each card must integrate into claude/issue-14478-… and be re-read there, and the reason the dev-side ablation discipline on a stacked card is load-bearing rather than ceremonial.

2. Disposition on the open question — A, accept the governance

⛔ Do not relocate EpochMs, ⛔ do not split the PR. The seat reached A independently, and posted it in the comment above before this report arrived, from its own run of the predicate; the report's option A is the same disposition on the same evidence.

The governance bit costs this stack nothing new: the base PR #15626 is itself governed (skills/objectstack-data/references/data-hooks.md), so the whole stack is under human merge either way. Option B would put a numeric time primitive somewhere no reader would look for it in order to dodge a governance bit — optimising against a gate rather than against a problem. Option C buys a second PR for one generated line and breaks the "lands together" sequencing for review value the human merge already provides.

3. ⛔ Card 4/6's title is NOT corrected yet — the delta does not reconcile

The report asks for #15679 to move 13 → 15. The seat will not move it on this arithmetic:

projected   api/ 12 + kernel/ 14 + system/ 13 + other 7 = 46
measured    api/ 12 + kernel/ 14 + system/ 15 + other 7 = 48
delta       +2, entirely inside system/

Two attributions were demoted, one in api/ (DeviceRequestResponse.interval) and one in system/ (AccessControlConfig.maxAge). The report explains the api/ column not moving by saying card 2/6's stated 12 already counted interval as an offender. Grant that, and the api/ demotion contributes 0 to the delta — which leaves one named system/ demotion having to account for +2. One of these must be true, and which one it is changes what card 4/6 says:

  • a second system/ key was demoted and not reported, or
  • the projected system/ 13 was wrong for a reason unrelated to the demotions, or
  • the api/ column did move and something else offsets it.

There is also a bucket that is named nowhere: the marker table marks shared/CorsConfig.maxAge, but shared/ appears in neither api/ | kernel/ | system/ nor the stated data + ui + ai + integration group. If shared/ carries offenders, they are in nobody's card.

The gate prints its offenders, so this is settled by enumeration, not by arithmetic — a per-directory offender list on c1949b9ac, cross-checked against the same list on 140e0b266. That measurement is running. ⛔ Until it lands, no card title moves, and cards 2/6–5/6 are not dispatched: dispatching a card whose declared population is off by two hands the next dev a target it cannot hit.

Standing state of this PR

Draft, and it stays one. ⛔ The seat will not flip it ready, enqueue it, enable auto-merge, or approve it — it is a governed-surface PR, and an agent seat never approves or merges one. Merge is a human action, into claude/issue-14478-…, and the required contexts report on #15626's run afterwards.


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Enumeration landed — the dev's numbers are confirmed, and the +2 was two different things

Independent re-run, ⛔ not a re-reading of the dev's output: pnpm install --frozen-lockfile then pnpm --filter @objectstack/spec check:duration-unit-keys in two dedicated detached worktrees, exit code captured before any pipe. Exit 1 on both heads; self-test passed in both (37 cases on target, 21 on baseline).

c1949b9ac  ✗ 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s)
           (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s))
140e0b266  ✗ 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s)

The baseline line reproduces character for character. All 48 and all 65 are the single rule unit-in-prose-not-in-name; no other rule class fires on either head.

directory baseline target delta
system/ 20 15 −5
kernel/ 18 14 −4
api/ 18 12 −6
data/ 4 3 −1
integration/ 2 2 0
ui/ 1 1 0
ai/ 1 1 0
shared/ 1 0 −1
65 48 −17

−17 = 6 instants + 11 markers, and it decomposes by directory exactly: api 4 markers + 2 instants, kernel 4 instants, system 5 markers, data 1, shared 1. The dev's split is exactly reproducible and complete.

The shared/ concern I raised — withdrawn, it was unfounded

shared/ contributes 0 offenders on the target, so there was nothing to bucket. It had exactly one at baseline — src/shared/http.zod.ts:139 maxAge, i.e. CorsConfig.maxAge — and that key is one of the 11 markers, so it left the offender set by being marked. Its absence from both buckets is correct, not a dropped column. I read a zero column as a missing column; the enumeration says otherwise.

The +2 in system/ — two causes, and only one of them was the dev's to report

Card 1/6's own attribution table names six system/ keys among its thirteen: object-storage.zod.ts:258 maxAge, auth-config.zod.ts:308/:330/:550, object-storage.zod.ts:197 expiresIn, disaster-recovery.zod.ts:127 ttl.

  • 20 − 6 = 14. Card 4/6 was filed declaring 13. So cards 1/6 and 4/6 disagreed with each other by one at filing time, before any dev touched anything. That is the +1 nobody reported, because it was never a dev action.
  • The demotion of object-storage.zod.ts maxAge leaves 5 markers applied: 20 − 5 = 15. That is the +1 the dev did report, correctly.

The same disagreement exists in api/, in the other direction. Card 1/6's list implies 18 − 5 markers − 2 instants = 11, while card 2/6 was filed declaring 12. The demotion of interval moved the measured value to 12 — so card 2/6's number is right, but by two filing-time errors cancelling, not because both cards were consistent. The dev's remark that card 2/6's 12 "corroborates" the demotion is therefore half true: it corroborates that interval is an offender, and it conceals that card 2/6 and card 1/6 never agreed on the exemption set.

Recorded as a filing-time lesson, since it is the seat's own error and not the dev's: the directory cards' declared counts were derived independently of card 1/6's exemption list, and two of the four disagreed with it. A stack whose cards each declare a population needs those populations derived from one source, or arithmetic like this cannot distinguish a dev's mistake from a filing mistake.

Both demotions independently confirmed as offenders on c1949b9ac

[unit-in-prose-not-in-name] src/api/auth-endpoints.zod.ts:287 `interval`
[unit-in-prose-not-in-name] src/system/object-storage.zod.ts:261 `maxAge`

Worth noting on the second: it is a second CORS-preflight maxAge, whose twin shared/CorsConfig.maxAge did get the marker. The dev marked one and demoted the other, and the asymmetry is justified in the PR body — the bucket-CORS vocabulary spells the field with its unit (MaxAgeSeconds / maxAgeSeconds / MaxAgeInSeconds) while the Fetch header does not. The two keys really do sit on opposite sides of the line.

Consequences applied

The dev's report survives verification in full. The one thing it got wrong was attributing all of the system/ delta to its own demotion, and that error made the seat's filing mistake visible, so it was a useful one.


Generated by Claude Code

os-sales pushed a commit that referenced this pull request Sep 5, 2026
…e issue ids (#15677)

Lap 1 regenerated content/docs/references/** but left the HAND-WRITTEN pages
teaching the old spellings. Three of them carried `os:check` blocks authoring
`cacheTtl`, so check:skill-examples was RED and lap 1 never ran it — it sits in
check:generated's "not run here" list and I did not run it separately.

cacheTtl -> cacheTtlSeconds: 14 occurrences on 13 lines, all the ApiEndpoint key.
retryAfter -> retryAfterSeconds: 14 occurrences, the ADR-0112 envelope field only.

Deliberately NOT swept, each verified rather than assumed:
  - the HTTP `Retry-After` response header (6 locals over 4 sites) — RFC 9110,
    a separate surface, and the thing the tombstone prose exists to protect;
  - `retry_after` as a RetryStrategy ENUM VALUE (errors.zod.ts z.enum);
  - `details.retry_after` on the wire, and the pre-existing
    `details.retryAfterSeconds` the runtime really emits (endpoint-policy.ts).

Also strips `(#14478 ruling B)` from the twelve tombstone prescriptions THIS card
wrote: check:doc-authoring forbids an internal issue id in customer-facing spec
text (maintainer ruling 2026-08-12), and the campaign's own earlier tombstones
already comply. The version and the FROM -> TO mapping stay — those are the
durable references AGENTS.md requires. Measured: the gate read 4 findings on the
base and 16 on my head; it now reads the base's 4 again, so this PR adds none.
Those 4 are card 1/6's (PR #15814) and are not mine to touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

⛔ This PR carries 4 check:doc-authoring findings — and the seat's earlier verification missed them

Surfaced by card 2/6's dev while running the docs gate family against its own base, which is this PR's head. Independently confirmed here rather than taken on report.

check:doc-authoring forbids an internal issue id in customer-facing spec text (maintainer ruling, 2026-08-12). Four retiredKey() prescriptions on c1949b9ac carry one:

packages/spec/src/api/websocket.zod.ts:484            + '(#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, '
packages/spec/src/api/websocket.zod.ts:523            + '(#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` '
packages/spec/src/kernel/context.zod.ts:33            + '(#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which '
packages/spec/src/kernel/startup-orchestrator.zod.ts:107  + '(#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` '

⚠️ The distinction is exact and worth stating, because #14478 appears in ~30 places on this head: the other occurrences are all // and /** */ code comments, which are not customer-facing and are not findings. Only these four sit inside a retiredKey() message — text a user meets when the parse refuses their key. The stack base 140e0b266 has eight such mentions, every one a comment, so its finding count is 0 and this PR introduced all four.

The campaign's own earlier tombstones already comply — hook.timeout, job.timeout, DriverOptions.timeout from PR #15626 carry no issue id in their prescriptions. Card 2/6 had copied this PR's pattern rather than theirs, wrote twelve more, then measured all three trees and stripped its own twelve back out.

The seat's miss, stated plainly

I posted a verification of this PR that called it clean. It checked the gate count, the per-directory populations, the governed-surface predicate and the release-notes directive — and did not run the gate farm. check:doc-authoring is one of the gates behind Lint & Repo Gates, a required context, so this is not cosmetic: on integration to main it goes red and blocks.

It went unseen for the reason I recorded on this PR at 09:07Z and did not then apply to my own review: heavy CI does not run on a stacked head, so nothing catches a gate the dev and the seat both skip. I named that risk and then reproduced it. The correction is structural rather than a resolution to be more careful — a stacked card's verification must run the gate family, because there is no second reader.

Routing

Card 2/6's dev was right to leave it alone: this PR's diff is out of its scope, and it said so instead of quietly reaching into it. ⚠️ It also reports that an over-broad substitution briefly stripped two of this PR's tombstones (WebSocketEvent.timestamp, SimplePresenceState.lastSeen) and that it restored them byte-for-byte before committing — verified: the websocket file on card 2/6's head touches only its own keys.

So the fix belongs here, on card 1/6's branch, as a follow-up lap on #15676. Dispatching it: strip the four (#14478 ruling B) fragments from the prescriptions only, ⛔ leave every code comment alone, ⛔ change no key, describe, schema or disposition, and re-confirm check:duration-unit-keys still reads 48 with the same exemption header.

⛔ Draft unchanged, not enqueued, no auto-merge, not approved.


Generated by Claude Code

…mbstone prescriptions (#15676)

`check:doc-authoring` rule 3 forbids an internal issue id in customer-facing
spec text (maintainer ruling 2026-08-12). The four `retiredKey()` prescriptions
this stack card added each opened with a `(#14478 ruling B)` parenthetical — a
citation-shaped token that resolves to nothing for the author who meets it the
moment their key is refused, and which the generated reference pages publish
verbatim.

The parenthetical goes; the sentence keeps everything actionable it carried —
the FROM to TO rename, that the value is unchanged, and the
`SimplePresenceState.lastSeen` neighbour caveat — matching the shape the
campaign's already-compliant tombstones use (`hook.timeout`, `job.timeout`,
`DriverOptions.timeout`). The internal anchor is untouched in the adjacent `//`
and `/** */` comments, which are not customer-facing and were never findings.

`content/docs/references/**` regenerated with `pnpm --filter @objectstack/spec
gen:docs` — no generated artifact was hand-edited.

check:doc-authoring: 4 findings before, exit 0 after.
check:duration-unit-keys: unmoved — 48 offender(s) among 215 duration-shaped
numeric key(s), (6 declared `EpochMs` instant(s), 11 declared
`externalVocabulary` mirror(s)).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Follow-up lap: internal issue id removed from the four tombstone prescriptions

check:doc-authoring rule 3 forbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12). This head carried exactly 4 findings, each a retiredKey() prescription. Pushed as 960580850.

What changed — the (#14478 ruling B) parenthetical is gone from the four prescription strings, nothing else. Each sentence still carries the FROM to TO rename, that the value is unchanged, and any surface caveat it had (the PresenceState.lastSeen neighbour note, the *At versus startTimeMs rationale), matching the shape the campaign's already-compliant tombstones use (hook.timeout, job.timeout, DriverOptions.timeout).

The #14478 anchor is untouched in the adjacent // and /** */ comments — those are internal readers, never findings. Repo-wide count of the id in tracked sources went 95 to 91: exactly the 4 flagged strings, no comment touched.

Files: 3 sources plus their 3 regenerated reference pages.

packages/spec/src/api/websocket.zod.ts
packages/spec/src/kernel/context.zod.ts
packages/spec/src/kernel/startup-orchestrator.zod.ts
content/docs/references/api/websocket.mdx
content/docs/references/kernel/context.mdx
content/docs/references/kernel/startup-orchestrator.mdx

content/docs/releases/ contributes zero files. No key name, .describe(), .meta(), schema shape, tombstone target, ADR-0087 disposition, RETIRED_KEYS_BY_MAJOR row, conversion or changeset was touched — the existing epoch-instant-and-external-vocabulary-exemptions changeset already covers this text.

Readings

Heavy CI does not report on this PR (base is a feature branch), so these are local, on pnpm install + a real build, exit codes captured before any pipe.

check:doc-authoring BEFORE (at c1949b9ac, exit 1):

✗ Internal issue-id reference(s) in CUSTOMER-FACING spec text:

  packages/spec/src/api/websocket.zod.ts:484  #14478  [retiredKey()]
    '(#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, '
  packages/spec/src/api/websocket.zod.ts:523  #14478  [retiredKey()]
    '(#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` '
  packages/spec/src/kernel/context.zod.ts:33  #14478  [via START_TIME_RENAMED]
    '(#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which '
  packages/spec/src/kernel/startup-orchestrator.zod.ts:107  #14478  [retiredKey()]
    '(#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` '

4 string(s): tombstone 4.

check:doc-authoring AFTER (at 960580850, exit 0):

✓ doc authoring guard: 394 files clean — no bare metadata literals.
✓ doc authoring guard: 46 published skill files clean — no internal issue-id references.
✓ doc authoring guard: 14871 customer-facing string(s) across 768 spec sources clean — no internal issue-id references (message 1191 · strictObject 3535 · tombstone 992 · describe 8732 · functionBuilt 254 · functionDeclared 167).
✓ doc authoring guard: sibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s), 85224 string(s) read in 1155 parsed source(s), no growth, no burn-down unrecorded.

check:duration-unit-keys — unmoved (exit 1, the epic's remaining work; the pin is the numbers, not the exit code):

✗ check:duration-unit-keys — 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s) (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)):

Others, all at 960580850:

  • pnpm --filter @objectstack/spec test — exit 0, Test Files 473 passed (473), Tests 12731 passed (12731).
  • pnpm --filter @objectstack/spec typecheck — exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck).
  • check:generated — first run exit 1, one stale artifact: content/docs/references/**. Regenerated with pnpm --filter @objectstack/spec gen:docs (the repo's own tooling, never hand-edited); the regenerated diff is exactly the 6 prescription rows and nothing else, so the staleness was entirely mine. Re-run exit 0, ✓ All 15 generated artifacts are up to date.
  • check:skill-examples — first run refused to verdict because packages/client-react/dist held no declarations: a non-reading, not a pass. Built that closure, re-ran: exit 0, ✅ 257 prose examples type-check across 3 surface(s).
  • check:nul-bytes — exit 0, 7632 files scanned; plus a direct control-byte scan of all 6 edited files, no hits.

Generated by Claude Code

…keys onto the epoch rename (#15676)

Adding `src/shared/epoch.zod.ts` and its generated reference page moved two
counts that no generator re-derives, and left four JSDoc `@example` blocks
teaching keys this branch turned into tombstones.

The counts (both re-derived by gates, both hand-kept by design):

- `packages/spec/llms.txt` schema-inventory heading 207 -> 208 and the
  `shared` row 13 -> 14. Per that gate's header the number is not the claim,
  the prose beside it is: the row's Key Schemas cell now names the epoch
  instant, so the sentence and the count say the same thing.
- `content/docs/getting-started/quick-reference.mdx` "Shared Protocol"
  (5 of 8) -> (5 of 9). Only M moves: the table is a curated subset by that
  page's own stated rule, so N stays 5 and no row is added.

The examples (no gate reads a JSDoc `@example`, so each was classified in
context by reading the schema it documents):

- `WebSocketEventSchema`'s two examples: `timestamp` -> `occurredAt`.
- `HealthStatusSchema`'s example: `"timestamp"` -> `"checkedAt"`.
- `PluginStartupResultSchema`'s example: the nested `health.timestamp` ->
  `"checkedAt"` — `health` carries `HealthStatusSchema`, so it is the same
  renamed key. Its sibling `"duration"` is untouched.

No key name, describe, meta, schema shape, tombstone, disposition, retired-key
row, conversion or changeset changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up lap on this branch — the two hand-kept counts, and four @example blocks teaching retired keys

Pushed to claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions: 960580850 -> 5b1b5308e. No new PR, no ready flip, no label, state or auto-merge change. Four files, seven lines; content/docs/releases/ contributes zero.

Adding packages/spec/src/shared/epoch.zod.ts and its generated page content/docs/references/shared/epoch.mdx moved two counts that no generator re-derives (reported in #15904), and left four JSDoc @example blocks teaching keys this branch turned into tombstones.

Built before measuringpnpm --filter @objectstack/spec build (34/34 declaration files emitted), because several gates return the PREREQUISITE-NOT-MET exit 3 without built declarations, and exit 3 is a void reading rather than a pass. Every exit code below was captured before any pipe (cmd out-redirect 2 to 1; EXIT=$?), never through head or tail.

The two counts — before and after, same tree

BEFORE, on 960580850:

$ pnpm --filter @objectstack/spec check:llms-txt        # EXIT=1
✗ packages/spec/llms.txt — the file advertises things this package does not have:

  packages/spec/llms.txt:94  [count] domain `shared` declares 13 schemas; src/shared/ holds 14
  packages/spec/llms.txt:80  [count] heading declares 207 schemas; packages/spec/src/ holds 208

$ pnpm check:quick-reference-counts                     # EXIT=1
✗ content/docs/getting-started/quick-reference.mdx — the declared sections do not match the page or the reference tree:

  content/docs/getting-started/quick-reference.mdx:209  [total] section "Shared Protocol" declares "of 8 schemas" but content/docs/references/shared/ publishes 9 page(s)

AFTER, on 5b1b5308e:

$ pnpm --filter @objectstack/spec check:llms-txt        # EXIT=0
✓ packages/spec/llms.txt: 97 claim(s) re-derived — every advertised symbol resolves against api-surface/ (17 entry points), every subpath is a real `exports` key, the inventory matches src/ (14 domains, 208 schemas) and the package table matches the workspace (68 published).

$ pnpm check:quick-reference-counts                     # EXIT=0
✓ content/docs/getting-started/quick-reference.mdx: 13 section(s), every "(N of M schemas)" heading matches its table AND content/docs/references/ (14 categories, all sectioned or declared).

What the prose says now, not just the numbers

check:llms-txt's header is explicit that the number is not the claim, the prose beside it is, so each row was re-read before its count moved:

  • llms.txt:94| shared | 13 | ... Retry Policy, Value Domain | became | shared | 14 | ... Retry Policy, Value Domain, Epoch Instant (EpochMs) |. The Key Schemas cell is a characterisation of the domain, so the schema that moved the count is now named in it. (That third cell is prose as far as the gate is concerned — it parses only cells 1 and 2 of each inventory row — so this is a readership decision, not a gate requirement.)
  • llms.txt:80 — heading (207 schemas) -> (208 schemas). The paragraph under it ("Counted as *.zod.ts modules under packages/spec/src/(domain)/ … verifiable from the installed package") stays true unchanged; the gate re-derives the heading against both the table sum and the tree, and 14 plus the other 13 rows sums to 208.
  • quick-reference.mdx:209## Shared Protocol (5 of 8 schemas) -> (5 of 9 schemas). Only M moved. N stays 5 and no row was added: the page's own "Reading the counts" paragraph declares the tables a curated subset where an N smaller than M is normal, and the gate's guidance says closing that gap is a decision rather than a fix. The section prose ("Common utilities used across all protocols.") does not enumerate, so it needed no change.

The four @example sites, each classified in its own context

No gate reads a JSDoc @example, so each was decided by reading the schema it documents:

Site Was Now Why
api/websocket.zod.ts:450 timestamp: Date.now() occurredAt: Date.now() Inside WebSocketEventSchema's own docblock (@example "Subscribe to channel"). That schema's timestamp is now a retiredKey tombstone; the live key is occurredAt.
api/websocket.zod.ts:460 timestamp: Date.now() occurredAt: Date.now() Same docblock, @example "Data change notification". Same schema, same rename.
kernel/startup-orchestrator.zod.ts:84 "timestamp": 1706659200000, "checkedAt": 1706659200000, HealthStatusSchema's @example. HealthStatus.timestamp is tombstoned; the live key is checkedAt (declared at :102, tombstone at :105).
kernel/startup-orchestrator.zod.ts:140 "timestamp": 1706659200000 "checkedAt": 1706659200000 Verified before changing, since it sits in a different docblock: it is PluginStartupResultSchema's @example, nested under "health", and that field is declared health: HealthStatusSchema.optional() (:176). So it IS the same renamed key. Its sibling "duration" in that example is a live key on this branch and was left untouched.

Each surrounding example still parses as a whole: WebSocketEventSchema takes type / channel / payload / occurredAt; HealthStatusSchema takes healthy / checkedAt / details; PluginStartupResultSchema takes plugin / success / duration / health.

A re-scan of this branch's own changed sources for the other renamed spellings (timestamp, lastSeen, startTime, ttl, timeout, duration in comment or docblock position) found no further @example teaching a retired key — the remaining hits are tombstone prescriptions and rename notes, which are correct as written.

The rest of the sweep, on 5b1b5308e

check:duration-unit-keys   EXIT=1 (unchanged, expected — this is the campaign's remaining backlog)
  ✗ check:duration-unit-keys — 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s) (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)):

check:doc-authoring        EXIT=0  (394 files clean; 14871 customer-facing strings across 768 spec sources clean; sibling baseline holds)
check:generated            EXIT=0  ("All 15 generated artifacts are up to date" — nothing went stale, nothing hand-edited)
check:nul-bytes            EXIT=0  (7632 text files, no raw control bytes)
check:doc-anchors          EXIT=0  (307 fragment links across 411 sources resolve — the edited heading's slug moves with its text, and nothing links to it)
check:docs-single-h1       EXIT=0
check:published-files      EXIT=0
check:error-status-conformance EXIT=0
check:docs-locale-catch-all    EXIT=0
pnpm --filter @objectstack/spec typecheck  EXIT=0
pnpm --filter @objectstack/spec test       EXIT=0  — Test Files 473 passed (473), Tests 12731 passed (12731)

check:doc-anchors, check:docs-single-h1, check:published-files, check:error-status-conformance and check:docs-locale-catch-all were added to the asked-for set by grepping for the scripts that actually read content/docs/getting-started/quick-reference.mdx and packages/spec/llms.txt; heavy CI does not run on this stacked PR (lint.yml and ci.yml both declare pull_request: branches: [main]), so local measurement is the only evidence here.

Filed while verifying, not fixed here: #15919api-surface/ records a name declared as both a const and a type under (type) only, so the value half of 132 such exports is invisible to check:api-surface. EpochMs is one of the 132; nothing about it is specific to this branch.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants