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
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…#14478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…4478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…on, regen docs (#14478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
📓 Docs Drift CheckThis PR changes 11 package(s): 42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37d908e5307a87afe119f3963ec8093f7e66617a && git checkout 37d908e5307a87afe119f3963ec8093f7e66617a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a4816a79d0396d0fd10696cdf95d66e55aef92d3 03335ab6d632829b7fc16f924fe9c49e45050779 && git checkout -B drift-repro a4816a79d0396d0fd10696cdf95d66e55aef92d3 && git merge --no-ff 03335ab6d632829b7fc16f924fe9c49e45050779
node scripts/docs-audit/affected-docs.mjs --json a4816a79d0396d0fd10696cdf95d66e55aef92d3
|
`turso-driver-options-door.test.ts` builds a `Parameters<TursoDriver['update']>[3]` literal — that argument IS `DriverOptions`, so the `timeout` key it wrote is the one renamed to `timeoutMs` here, not a driver-local key. Same magnitude (milliseconds), no value conversion. `TursoDriverConfig.timeout` in `turso-driver.ts` and the `timeout` in `src/spec/turso.zod.ts` are a different key on the driver's own connection schema and stay as they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
|
Contract review (clause ②) PASS at head ⛔ Governed ( Implemented-by: Generated by Claude Code |
⛔ Correction to my own landing simulation: this repo squash-merges, and my five-merge dry run modelled the wrong landing mode
What I got wrongThat simulation merged the five cards into this branch with Measured, with a control so the zero is a reading rather than a dead query: 0 merge commits in ⇒ My "the five merges are clean end to end" holds only for a landing mode that does not exist here. ⛔ Do not plan against it. What a squash actually costs a stacked branchA squash keeps the content and destroys the ancestry link, so the next card's merge base rewinds past everything already landed: Card 3/6 therefore re-applies content this branch already holds, and collides. Measured both ways:
The two extra are A repair lap for #15906 is dispatched, carrying the acceptance test that matters: card 2/6's twelve What this changes for the rest of the landingOne repair lap per remaining card, after its predecessor lands — #15938, #15988, #16022 in turn. Each is mechanical (ledger rows plus regenerations) and each is mine to run, except that #15988 stays a human merge on governed surface. What survives from that simulation unchangedThose results were about content, not merge mode, and they still hold:
Generated by Claude Code |
|
Heads-up from the domain:spec seat for the #14478 stack ( Generated by Claude Code |
|
Correction to the heads-up above (2026-09-05T22:52Z): the squash Generated by Claude Code |
Measured the fold against this branch — the ledger does conflict, and the reason is the same one that just bit card 3/6
That heads-up expected a conflict-free text merge, on the reasoning that the fold's rows "sit beside, not on" this stack's duration-unit entries. The semantic half of that is right — the two sets of rows are unrelated. The git half does not follow, and the measurement says so: Under ⭐ Rows sitting beside each other is exactly what conflicts. An add/add at the same insertion point in a list is a conflict even when neither side touches the other's lines — git has no way to choose an order. This is not a hypothetical: card 3/6's merge into this branch, measured an hour ago, conflicts on this same file in three hunks where the trunk side of every hunk is empty and only 3/6's rows are present. Nothing disagrees; the two just arrive at the same line. So the ledger conflict is expected, benign in shape, and resolved by keeping both sides' rows — with the count checked rather than eyeballed afterwards, because a resolution that silently drops one side still compiles and still passes most gates. Not urgent, and deliberately not acted onThis is the Generated by Claude Code |
…he key name (#15678, stack 3/6) (#15906) * feat(spec): declare the two duration-rule exemptions on the schema (#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 * feat(spec)!: move the six epoch instants onto EpochMs and mark the external-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 * feat(spec): publish the externalVocabulary standard on the reference 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 * docs(changeset): the two duration-rule exemptions (#15676) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * chore(spec): regenerate the derived artifacts and fix the consumers the 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 * wip(spec): rename the 12 api/ duration keys, tombstones on the old spellings (#15677) The schema half of stack card 2/6. Gate reads 48 -> 36 with src/api/ at 0. Readers, registry entries and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers, ADR-0087 registrations, ledger row for the api/ renames (#15677) 12 retired-key entries, one D2 conversion (api-endpoint-cache-ttl-to-cache-ttl-seconds — apis: is a stack collection) and five semantic entries for the eleven runtime-emitted / construction-argument keys. Readers moved in runtime, metadata, rest-adjacent tests, dogfood fixtures and the showcase example; liveness/api.json carries the renamed row plus the dead tombstone row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): tombstone refusal tests, alias retarget, regenerated artifacts (#15677) Per-key refusal tests assert the prescription (code + rename text), not a bare throw. Two readers the key-name grep missed and tsc/the tombstones caught: the ApiEndpoint alias table (cacheTTL/ttl/cache retargeted onto cacheTtlSeconds — an alias must point at a key the schema accepts) and the showcase endpoint fixture in metadata-type-api-registration.test.ts. Regenerated: authorable surface + defaults, reference docs, liveness state-counts. skills/objectstack-api/SKILL.md carries the rename (governed; net 0 lines, file and package both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the twelve api/ duration renames (#15677) @objectstack/spec minor with the BREAKING banner naming every renamed key, the six adr-0087 ids registered, the retryAfter wire note, and the disposition split (one D2 conversion, five semantic entries). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(docs-audit): declare the conversion-replay exclusion kind (b) relied on incidentally (#15677) An ADR-0087 conversion fixture copies a routable metadata kind verbatim, so an `apis:` fixture carries `method:` beside `path:` — because that is what an ApiEndpoint IS. Ruling A named conversions/registry.ts as the guard's target but enforced it with requireMethodSignal, a content proxy that held only while no conversion fixture carried a verb. This card's apis: conversion is the first that does, and the live pin red exactly as designed. The fixture is correct and stays. The exclusion moves to CONVERSION_REPLAY_FILE_RE, which states the structural fact instead of testing a symptom, and three cases pin the new guard as load-bearing rather than incidental in its turn. NOT restricting kind (b) to packages/spec/src/api/**: that is the invariant the live pin asserts, and enforcing it in the walk would make that pin true by construction — a check that cannot fail. Measured tail-neutral: the scan census is byte-identical to the base (17 route sources, 12 call sites, 5 contract declarations, 78 tails, 61 reachable). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs: move the hand-written pages onto the renamed keys, and strip the 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 * docs(spec): drop the internal issue id from the four epoch-instant tombstone 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 * chore(spec): regenerate the reference page the merge deferred (#15677) The merge with card 1/6's advanced tip (9605808) touched api/websocket.zod.ts on both sides. The schema source auto-merged; the generated content/docs/references/api/websocket.mdx is routed to merge=os-regen, so the driver deferred it and the merge kept OUR side — silently dropping card 1/6's half. Regenerating from the merged tree is what repairs it, and it carries both sides: their two stripped prescriptions land (issue-id occurrences 2 -> 0) while my four renamed keys stay (6 -> 6). Not hand-edited and not resolved by taking a side: the bytes come from `pnpm --filter @objectstack/spec check:generated --fix` on the merged tree, and the staged diff was read before committing (`git diff` reads clean over this trap; only the staged diff shows it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): rename the 14 kernel/ duration keys, tombstones on the old spellings (#15678) The schema half of stack card 3/6. Gate reads 36 -> 22 with src/kernel/ at 0. Readers, registrations and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers and ADR-0087 registrations for the kernel/ renames (#15678) 14 retired-key entries and five semantic entries. No D2 conversion on this card: none of the twelve defs is a stack collection member or a stored sys_metadata row (stack.zod.ts declares no eventBus / startup / plugin-security root), so the conversion chain has no seam that would see one. Readers moved in core's health monitor, the kernel and contracts test suites, and the hand-written lifecycle protocol page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): tombstone refusal tests and regenerated reference pages (#15678) Per-key refusal tests assert the prescription (issue code + rename text), not a bare throw, plus acceptance pins at the same magnitudes and defaults. Two deliberate NEGATIVE controls: RuntimeConfig.resourceLimits.timeout names no unit so it stays bare, and EventSourcingConfig.snapshotRetention is a count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the fourteen kernel/ duration renames (#15678) @objectstack/spec minor + @objectstack/core patch, BREAKING banner naming every renamed key, adr-0087: registered with the five semantic ids. Documents the two unit collisions the rename removes (responseTime hours vs ms; uptime ms vs the seconds-valued GET /health) and the three keys deliberately left bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): correct why `RuntimeConfig.resourceLimits.timeout` is left bare (#15678) The changeset stated the key "names no unit anywhere in its prose". That is false and it would have published into the CHANGELOG: the JSDoc directly above the key (plugin-security-advanced.zod.ts:291-293) says "Execution timeout in milliseconds". Its `.describe('Maximum execution time')` names none. The accurate reason is stronger: `check:duration-unit-keys` reads `.describe()` and `.meta({ description })` and does NOT read JSDoc, so this key's unit sits in a channel the gate cannot see. The gate therefore lists it among the duration-shaped keys but deliberately does not judge it — neither an offender nor an exemption. That channel gap is tracked in #15939. The same false clause appeared in three more places on this branch and is corrected in all of them: the ADR-0087 semantic migration entry (its `reason` publishes into the generated upgrade guide), its generated mirror in registry.ts (via gen:migration-registry, never hand-edited), and the comment above the pin test. Prose only. No key renamed, no schema, `.describe()`, JSDoc, tombstone or pin assertion touched; the key stays bare and stays out of the rename. `check:duration-unit-keys` still reads 22 offender(s) among 215 duration-shaped numeric key(s) in 805 source file(s), `kernel/` at 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * chore(spec): regenerate the os-regen artifacts on the merged tree Discharges the deferral the merge commit recorded. `check:generated` proved exactly one artifact set stale (content/docs/references/**) after the spec build re-derived authorable-surface/kernel.json, so only that one was regenerated. Both files re-derive this card's [RETIRED] rows on top of the trunk's side; the trunk's own renames (checkedAt, startedAt) survive in both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --------- Co-authored-by: Claude <noreply@anthropic.com>
…'s zod schemas, and convert the one turso offender it finds (#16022) * feat(spec): declare the two duration-rule exemptions on the schema (#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 * feat(spec)!: move the six epoch instants onto EpochMs and mark the external-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 * feat(spec): publish the externalVocabulary standard on the reference 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 * docs(changeset): the two duration-rule exemptions (#15676) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * chore(spec): regenerate the derived artifacts and fix the consumers the 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 * wip(spec): rename the 12 api/ duration keys, tombstones on the old spellings (#15677) The schema half of stack card 2/6. Gate reads 48 -> 36 with src/api/ at 0. Readers, registry entries and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers, ADR-0087 registrations, ledger row for the api/ renames (#15677) 12 retired-key entries, one D2 conversion (api-endpoint-cache-ttl-to-cache-ttl-seconds — apis: is a stack collection) and five semantic entries for the eleven runtime-emitted / construction-argument keys. Readers moved in runtime, metadata, rest-adjacent tests, dogfood fixtures and the showcase example; liveness/api.json carries the renamed row plus the dead tombstone row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): tombstone refusal tests, alias retarget, regenerated artifacts (#15677) Per-key refusal tests assert the prescription (code + rename text), not a bare throw. Two readers the key-name grep missed and tsc/the tombstones caught: the ApiEndpoint alias table (cacheTTL/ttl/cache retargeted onto cacheTtlSeconds — an alias must point at a key the schema accepts) and the showcase endpoint fixture in metadata-type-api-registration.test.ts. Regenerated: authorable surface + defaults, reference docs, liveness state-counts. skills/objectstack-api/SKILL.md carries the rename (governed; net 0 lines, file and package both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the twelve api/ duration renames (#15677) @objectstack/spec minor with the BREAKING banner naming every renamed key, the six adr-0087 ids registered, the retryAfter wire note, and the disposition split (one D2 conversion, five semantic entries). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(docs-audit): declare the conversion-replay exclusion kind (b) relied on incidentally (#15677) An ADR-0087 conversion fixture copies a routable metadata kind verbatim, so an `apis:` fixture carries `method:` beside `path:` — because that is what an ApiEndpoint IS. Ruling A named conversions/registry.ts as the guard's target but enforced it with requireMethodSignal, a content proxy that held only while no conversion fixture carried a verb. This card's apis: conversion is the first that does, and the live pin red exactly as designed. The fixture is correct and stays. The exclusion moves to CONVERSION_REPLAY_FILE_RE, which states the structural fact instead of testing a symptom, and three cases pin the new guard as load-bearing rather than incidental in its turn. NOT restricting kind (b) to packages/spec/src/api/**: that is the invariant the live pin asserts, and enforcing it in the walk would make that pin true by construction — a check that cannot fail. Measured tail-neutral: the scan census is byte-identical to the base (17 route sources, 12 call sites, 5 contract declarations, 78 tails, 61 reachable). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs: move the hand-written pages onto the renamed keys, and strip the 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 * docs(spec): drop the internal issue id from the four epoch-instant tombstone 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 * chore(spec): regenerate the reference page the merge deferred (#15677) The merge with card 1/6's advanced tip (9605808) touched api/websocket.zod.ts on both sides. The schema source auto-merged; the generated content/docs/references/api/websocket.mdx is routed to merge=os-regen, so the driver deferred it and the merge kept OUR side — silently dropping card 1/6's half. Regenerating from the merged tree is what repairs it, and it carries both sides: their two stripped prescriptions land (issue-id occurrences 2 -> 0) while my four renamed keys stay (6 -> 6). Not hand-edited and not resolved by taking a side: the bytes come from `pnpm --filter @objectstack/spec check:generated --fix` on the merged tree, and the staged diff was read before committing (`git diff` reads clean over this trap; only the staged diff shows it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): rename the 14 kernel/ duration keys, tombstones on the old spellings (#15678) The schema half of stack card 3/6. Gate reads 36 -> 22 with src/kernel/ at 0. Readers, registrations and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers and ADR-0087 registrations for the kernel/ renames (#15678) 14 retired-key entries and five semantic entries. No D2 conversion on this card: none of the twelve defs is a stack collection member or a stored sys_metadata row (stack.zod.ts declares no eventBus / startup / plugin-security root), so the conversion chain has no seam that would see one. Readers moved in core's health monitor, the kernel and contracts test suites, and the hand-written lifecycle protocol page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): tombstone refusal tests and regenerated reference pages (#15678) Per-key refusal tests assert the prescription (issue code + rename text), not a bare throw, plus acceptance pins at the same magnitudes and defaults. Two deliberate NEGATIVE controls: RuntimeConfig.resourceLimits.timeout names no unit so it stays bare, and EventSourcingConfig.snapshotRetention is a count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the fourteen kernel/ duration renames (#15678) @objectstack/spec minor + @objectstack/core patch, BREAKING banner naming every renamed key, adr-0087: registered with the five semantic ids. Documents the two unit collisions the rename removes (responseTime hours vs ms; uptime ms vs the seconds-valued GET /health) and the three keys deliberately left bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): rename the 15 system/ duration keys, tombstones on the old spellings (#15679) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers, tombstone refusal tests and ADR-0087 registrations for the system/ renames (#15679) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): regenerated artifacts and reference pages for the system/ renames (#15679) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * test(spec): fix the three new metrics pins — required label, and assert the prescription explains the non-mechanical name (#15679) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the fifteen system/ duration renames (#15679) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): rename the 7 data/ ui/ ai/ integration/ duration keys, tombstones on the old spellings (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): readers, ADR-0087 registrations and regenerated artifacts for the 7 renames (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * test(spec): tombstone refusal pins for the 7 renames; narrow credential derivation so a retirement tombstone is not read as a secret (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * chore(i18n): regenerate the metadata-form bundles for the dashboard key rename, translations carried over (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): the seven data/ ui/ ai/ integration/ duration renames, plus the auto-arm sibling (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(changeset): declare the ADR-0087 disposition on the driver-memory rename (#15680) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip(spec): widen check:duration-unit-keys to every workspace package's src (#15682) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * feat(driver-turso)!: rename the published config timeout to timeoutMs, tombstone on the old spelling (#15682) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(scripts): declare the three workspace-enumerator exports the widened gate imports (#15682) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(service-datasource): read the canonical turso `config.timeoutMs` at the shared libSQL seam `buildTursoDriverConfig` still consulted `config.timeout` after that authored key was renamed to `timeoutMs` and tombstoned, so a datasource authored the canonical way had its timeout silently dropped. `TursoConfigSource.config` is an untyped string-keyed bag, so tsc could not see the rename through it, and the covering test authored the retired spelling at all three of its sites and stayed green over the defect. The reader now reads `config.timeoutMs`; the driver key it lands on stays `timeout` (published-but-inert, must not be ratified by a rename). No fallback arm for the retired spelling — the sqlite `filename` and mongo `url` arms in `default-datasource-driver-factory.ts` set that precedent, and both authoring and stored-row rehydration deliver the canonical key already. The covering test moves to the canonical spelling and gains contract-derived cases that read the schema's own tombstones, so they hold for the next rename without being edited. The two sibling pins that author the same spec move with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * docs(spec): correct the exclusion pin's stale comment to the fixture it actually builds (#15682) The behavioural exclusion pin in `check-duration-unit-keys.ts` was described against an earlier, smaller fixture: "asserts the walk finds ONE file. Seven offenders on disk, one in the verdict." The code below it writes 8 excluded shapes plus 2 real source files and asserts `walked.files === 2 && walked.findings.length === 2` — three numbers the prose got wrong. The code is right and the comment was stale, so only the comment moves. The assertion, the `excluded` list and the second `expect` ("an excluded file is not merely unjudged, it is never read") are untouched; the wider fixture is the point of the pin and is deliberately kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --------- Co-authored-by: Claude <noreply@anthropic.com>
…gainst main (#14478) Card 6/6's `already-registered` was honest at its own base, where card 5/6 had already registered `turso-config-timeout-to-timeout-ms`. Measured against `main`, this branch's combined diff is what ADDS that registration, so the honest disposition at this level is `registered` — which is what check-adr-0087-registration.mjs --base origin/main names. The explanation moves to a second comment on its own line: every whitespace-separated token after the id on the marker line itself is parsed as another id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Three text conflicts, all co-insertions — both intents stack, nothing dropped:
packages/spec/src/migrations/registry.ts
step 18's hand-written `rationale` (both sides appended a sentence: main's
keeps its adjacency to the families it names, this branch's "Finally, …"
stays final), plus one add/add in the generated `semantic:18` region
(`epoch-instant-keys-renamed` beside `esignature-config-deadline-keys-retired`)
and one in `retired-key:18` (`data/FilePersistenceConfig:autoSaveInterval`
beside `data/ESignatureConfig:reminderDays`). The two generated regions are
re-derived from `src/migrations/entries/**` by the regeneration commit that
follows; the entry files themselves merged as pure adds.
packages/spec/src/type-alias-convention.pin.test.ts
a machine-checked count stated in three places. 825 (base) - 13 (main's
whole-family retirement) + 1 (this branch's `EpochMs`) = 813, and 813 is
what the file's own regex counts in the merged tree — not arithmetic.
packages/spec/llms.txt
the schema inventory total. 207 - 3 + 1 = 205, matching the per-domain
table (which merged cleanly) and the merged `*.zod.ts` census.
The 16 `merge=os-regen` paths both sides moved take origin/main's side here,
per scripts/pm/os-regen-merge.sh step 2; the regeneration commit that follows
re-derives them on the merged tree. No `content/docs/releases/` edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…4478) Discharges the os-regen deferral the merge commit recorded. Regenerated in the order the generators require — `gen:migration-registry` writes a source file, so the spec is rebuilt before `gen:api-surface` reads `dist/*.d.ts`: gen:migration-registry, build, gen:api-surface, gen:export-origins, gen:declaration-map, gen:docs `check:generated` then reports all 15 generated artifacts up to date. The ledger keeps every row from both sides and invents none: 356 entry files at the merge base, +27 from main, +80 from this branch, 463 in the result — set equality, not just a matching total. `check:migration-registry` reads the same population back as 186 semantic, 161 retired-key, 116 retired-def. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Ready for the human merge — and first, a correction to something I said twice on this PR
⛔ The correction: six required contexts, not sevenAt That was wrong, and not because the file changed under me — it never said that: The registry's own history says so out loud: commit ⇒ The argument those two comments made is unaffected: The landing blocker filed at
|
Part of #14478
Fixes #14519
Executes the maintainer ruling recorded on #14478 at comment
5518649320— ruled B, verbatim 「14461 你不处理,其他同意」, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」: a spec-source gate for duration-shapedz.number()keys with no grandfathered baseline, plus an ADR-0087 conversion of every offender the ruling named, in one PR. Dispatched by thedomain:specseat (claim5547289696),CONTRACT_REVIEW_TIER.Clause-②: yes — seven published authorable keys are renamed.
needs:contract-reviewis carried on the card and on this PR; the isolated contract-tier review is the seat's to dispatch, and the gate stays on both carriers until it clears.⛔ Landing: this PR is GOVERNED — draft is its finished state, a human merges it
Draft is not "unfinished" here. One path in this diff is on the governed-surface register, and one hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」 (maintainer, 2026-08-18; AGENTS.md Prime Directive #14). Measured, not assumed:
node scripts/pm/check-governed-merges.mjs --test skills/objectstack-data/references/data-hooks.md packages/spec/src/data/hook.zod.ts→ exit 3,skills/** ×1 — the published skills catalog: skills/objectstack-data/references/data-hooks.md, with the spec path listed as not on the register; the control leg (--test packages/spec/src/data/hook.zod.tsalone) exits 0. The other 62 files are ordinary.The
skills/**edit is kept on purpose and must not be dropped to escape governance: onorigin/mainthat reference namestimeoutat:109-110(an authored example,timeout: 5000,) and in the prose lists at:871and:885; landing the rename without it would have the published skills catalog teach a spelling the schema now refuses — a defect strictly worse than a human merge. So: ⛔ no seat flips this PR ready, enqueues it, arms auto-merge, or approves it (an agent-operated approver account counts as a seat). Review is requested fromhotlong(a governed approver); the human merge is the review record. Nothing about the change itself is in question — this section is about how it lands.The ruling was adopted on a measured radius of five keys (plus the two #14519 keys), three readers and one in-repo author. The card's own inventory said that of 178 unit-naming describes "for most of them the unit is also in the key name". That premise is false by an order of magnitude. Deriving the offender set mechanically from
packages/spec/src/**with the ruling's own rule — az.number()chain whose.describe()names a time unit and whose key name carries none — finds 70 offenders onca46f8f12(six more were detector false positives, since removed: ordinal "second",minas minimum). The ruling's seven are among them. After this PR converts those seven, 65 remain, in four classes (full list in the gate's own output,pnpm --filter @objectstack/spec check:duration-unit-keys):RestApiEndpoint.timeout/cacheTtl,WebSocketConfig.pingInterval/heartbeatInterval/timeout,CollaborationSessionConfig.idleTimeout(ms — the same name the tenant key had in seconds),EventQueueConfig.retention(days),RegistryConfig.syncInterval/ttl,DashboardConfig.refreshIntervalHttpCacheConfig.maxAge/staleWhileRevalidate/staleIfError(Cache-Control directives), CORSmaxAgetwice, better-authexpiresInthree times, S3 presignexpiresIn, pgstatementTimeout, DNSttl, OAuth device-flowintervalStartupResult.duration/totalDuration,PluginHealth.uptime/responseTime,TraceSpan.duration,ApiError.retryAfter(the wire envelope)timestamp,lastSeen,startTime,registeredAt,createdAtI stopped at the ruling's seven and did not convert the other 65. Converting them is ten times the radius the maintainer measured when adopting B, includes keys whose spelling is fixed by HTTP, OAuth, AWS, DNS and better-auth, and includes runtime-emitted shapes for which an ADR-0087 conversion has no seam — decisions the ruling did not make. Under the four-axis frame the rule stands; what is undecided is the population: literal (convert all 65), or the rule minus instants and external-vocabulary mirrors (declared in-schema, never as a gate ledger), or the rule restricted to the authorable metadata-type surface. That is put to the maintainer in the report as
needs_decision.Consequence: this PR is red on its own gate, by design. The gate is wired into
lint.ymlas the last step of theLint & Repo Gatesjob so it masks nothing behind it, and it prints the 65 remaining offenders. ⛔ It has no baseline and no exception list — the dispatch forbade a gate that passes only because its exceptions were enumerated. It goes green the day the remainder is converted or the population is narrowed by decision.What this PR does
1. The gate —
packages/spec/scripts/check-duration-unit-keys.tspnpm --filter @objectstack/spec check:duration-unit-keys(self-test first, wired ascheck:self-test-wiredrequires; classifiedNO_GENERATORincheck-generated.ts; declared populationpackages/spec/src/**via theROOT_DIR_WATCH_HINTSidiom, held against the scan root in the self-test).The rule, one direction each way: a property whose value is a
z.number()/z.int()/z.coerce.number()chain and whose.describe()names a time unit must carry that unit as a token of its key name (Ms/Seconds/Minutes/Hours/Days, plus the knex-inheritedMillis), and the token must agree with the prose —ttlMsdescribed "in seconds" is refused too.{ value, unit }pairs are recognised structurally by the siblingunitkey; duration literals ('14d') are strings and outside the population. Calendar positions ("day of the month (1-31)") and rates ("requests per second") are skipped. Singular prose forms count only with a number in front ("1 second"), which is what keeps the ordinal "second pass" andminas minimum out.Why
packages/spec/scripts/and notpackages/lint:@objectstack/lintvalidates a customer's metadata graph at build time — pure(stack) => Issue[]functions the CLI and AI authoring share. This gate reads this package's own source and judges how a schema is declared; it has no stack to validate and nothing a customer could run it on. That is the shape of every other spec source audit (check-exported-any,check-dual-source-exports,check-error-code-provenance).Why the name-only rule is a census row and not a verdict: judged by name alone ("a key called
sessionTimeoutwith no unit anywhere") the rule fired 44 times onca46f8f12, and most were counts wearing a duration's vocabulary —contextWindow,slidingWindowSize,snapshotInterval("every N events"),reflectionInterval("every N interactions"),backoffMultiplier,staleKeys. A rule that cannot tell a window of tokens from a window of seconds would either grandfather those by name or teach authors to appendMsto a count.--liststill prints the ~25 genuine unit-nowhere keys (the #14519 shape:logging.flushInterval,tracing.exportTimeout,tenant.schemaCacheTTL,plugin-lifecycle-advanced.shutdownTimeout, …) so the population stays visible.2. The seven conversions — one ADR-0087 entry each, ⛔ no alias, no transition window
HookSchema(hooks[])timeout→timeoutMsretiredKey()tombstone on the strict shape (carries the rename;tscnever+ parse); aliastimeoutms → timeoutremovedhook-timeout-to-timeout-ms(retired from the load path) + step 18JobSchema(jobs[])timeout→timeoutMstimeoutMs → timeoutremoved;system/Job:timeoutregisteredjob-timeout-to-timeout-ms+ step 18DriverOptionsSchematimeout→timeoutMsdata/DriverOptions:timeoutregistereddriver-options-timeout-to-timeout-ms(a per-call options object has no stack seam)MetadataManagerConfigSchemacache.ttl→cache.ttlSeconds;cache.databaseLoader.ttl→cache.databaseLoader.ttlMsmetadata-manager-config-cache-ttl-unit-in-keyDatabaseLevelIsolationStrategySchema/TenantSecurityPolicySchemaconnectionPool.idleTimeout→idleTimeoutSeconds;accessControl.sessionTimeout→sessionTimeoutSeconds; describes now say "in seconds"tenant-timeouts-unit-in-keyEvery old spelling is refused with a prescription naming the new key (pinned per schema in
hook.test.ts,job.test.ts,driver.test.ts,metadata-loader.test.ts,tenant.test.ts, each with atsc-channel case).Hookis not on the authorable surface (itshandleris a function), so it has noRETIRED_KEYS_BY_MAJORrow; the two nestedttlkeys and the tenant keys are not surface rows either.#14519 is genuinely completed and carried as
Fixes: both tenant keys carry their unit in the name and their.describe()now says "in seconds" — pinned, because.describe()is whatcontent/docs/references/**publishes and the JSDoc above a key is not, so the reference-page reader was the one reader who never saw the unit. #14519's own proposed fix (add the unit to the describe only) is exactly what the new gate refuses, which is why the keys were renamed instead.3. Readers, in the same PR
packages/metadata/src/loaders/database-loader.ts(:136,:252)DatabaseLoaderCacheOptions.ttl→ttlMs;ttl: cacheOpts?.ttlMs ?? 60_000(same magnitude)packages/objectql/src/hook-wrappers.ts:358meta.timeout→meta.timeoutMspackages/spec/src/contracts/job-service.tsJobScheduleOptions.timeout→timeoutMs— renamed in lockstep: a contract key that re-spelled the value without its unit would reintroduce one layer down exactly the ambiguity the rename removedpackages/runtime/src/app-plugin.ts:1098{ retryPolicy, timeoutMs: job.timeoutMs }packages/services/service-job/src/run-with-policy.ts:127,db-job-adapter.tsoptions?.timeoutMs;withoutPolicystripstimeoutMsexamples/app-showcase/src/automation/jobs/index.ts:23timeoutMs: 300000packages/spec/src/data/hook.form.ts:71timeoutMs(i18n bundles regenerated withnode scripts/check-i18n-bundles.mjs --write)hook.json/job.jsontimeoutMslive rows with the same anchors;timeoutrows kept asdeadtombstone rows (theretiredKeyroute keeps the key in the walked shape)content/docs/automation/jobs.mdx,content/docs/protocol/kernel/metadata-service.mdx,packages/metadata/README.md:180skills/objectstack-data/references/data-hooks.md(governed)timeoutMsin the example and the two prose listsThe README candidate is in scope, and was changed: it sits in the package whose reader is renamed here, it demonstrates the exact spelling the schema now refuses, and
check:skill-examples-style example rot is the failure this rule exists for. Cost one line. The bare-key sweep was not done:playwright.config.ts,sqlite-occupancy.ts,serve-process.ts, the compose file, SMTP transporttimeout,RegistryConfig.cache.ttl(its own schema, in the remainder) are different keys on different schemas and were left alone — every hit was disambiguated to its declaring schema first.Seat coordinates re-measured and confirmed:
database-loader.ts:252(not:204),hook-wrappers.ts:358(not:357),run-with-policy.ts:127,jobs/index.ts:23. One seat assertion the tree contradicts: the card says the two tenant keys are "on the authorable surface";authorable-surface/system.jsoncarries noTenant*row at all (the only tenant rows arecloud/ProvisionTenantRequest:*), so noRETIRED_KEYS_BY_MAJORentry exists for them and none is owed.4. Changesets — level derived from the repo's rule, not from the dispatch
scripts/check-changeset-no-major.mjs(header: breaking changes ship asminorduring the launch window; the BREAKING banner and the ADR-0087 disposition are the carriers) +pr-automation.yml"WHICH LEVEL" + precedent inpackages/spec/CHANGELOG.md(**BREAKING** … shipped as minor):@objectstack/specminor, BREAKING banner,adr-0087: registerednaming the five ids;@objectstack/metadataminor, BREAKING (DatabaseLoaderOptions.cache.ttl→ttlMsis an exported interface member),adr-0087: registered metadata-manager-config-cache-ttl-unit-in-key(the gate refusedalready-registeredfor an id this diff adds — measured, corrected);@objectstack/objectql,@objectstack/service-job,@objectstack/runtimepatch — they read the renamed key; no public surface of their own moves.node scripts/check-adr-0087-registration.mjs --base origin/main→✓ check-adr-0087-registration: 2 declared-breaking changeset(s), each carrying an ADR-0087 disposition.5. Skills line readings (the
skills/**diff)skills/objectstack-data/references/data-hooks.md: 979 → 979 lines. Whole packageskills/objectstack-data/**/*.md: 3736 → 3736. Allskills/**/SKILL.md: 6835 → 6835. Net zero; no re-wrap, no content bought.Out-of-scope finding, filed (not ridden along)
MetadataManagerConfig.cache.{enabled, ttlSeconds, maxSize}are declared and documented but read by nothing — onlycache.databaseLoaderreaches a runtime #15624 —MetadataManagerConfig.cache.{enabled, ttlSeconds, maxSize}are declared and published but read by nothing; only the nestedcache.databaseLoaderblock reaches a runtime (metadata-manager.ts:618,:648). Surfaced by tracing the readers ofcache.ttlfor this rename; the ruling renames the key and does not ask whether it is read, so the ADR-0049 question is filed rather than answered here.Verification (final head
99999540a)pnpm --filter @objectstack/spec build→VERDICT command-exit 0(161s, then 196s after the tombstone text edit);check:generated --fix→✓on 14 of 15, the 15th (check:react-declaration-parity) needs objectui's manifest and cannot run here.pnpm --filter @objectstack/spec typecheck(src + scripts + test-typecheck) →VERDICT command-exit 0.pnpm --filter @objectstack/metadata --filter @objectstack/objectql --filter @objectstack/service-job --filter @objectstack/runtime typecheck→VERDICT command-exit 0.hook,job,driver,tenant,metadata-loader,conversions/,migrations/,alias-integrity,retired-key-migrate-sentence,strict-object,hook-body,hook-form,check-generated-ledger→Test Files 16 passed,Tests 719 passed(after the last three job fixtures were renamed).database-loader.test.ts86 passed; objectqlhook-binder+hook-metrics32 passed; service-jobdb-job-adapter.timeout+cron-job-adapter+interval-job-adapter40 passed.check:liveness→✓ every governed-type property … is classified(hook 22 classified, live 19 dead 3; job 16, live 15 dead 1);check:i18n→ 0 after regeneration;check:skill-examples→ 0 (needsclient-reactbuilt first — measured: a stale-dist refusal until then).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(147 commands; exit codes captured before any pipe): all green on the final head except —check:duration-unit-keys1 (the 65 remaining offenders, by design);check:dual-build-cjs-loads3 PREREQUISITE NOT MET (needs a fullpnpm build; NOT MEASURED);check-engine-split-ratio --days 902 (shallow clone refusal; NOT MEASURED);check-required-contexts --verify-required-set2 in the farm (HTTP 401 without the proxy) and 0 re-run asNODE_OPTIONS=--use-env-proxy;check:pm-dispatch-gatesrun detached per its own header →✓ dispatch-gates self-test: 1445 cases pass.(exit code not captured — detached).check:docs-audit-scopered once mid-farm on an earlier head (self-test case "every contract declaration admitted is a packages/spec API declaration") and green on every re-run, including the pristine base worktree. Not new and not this PR's:check:skill-examplesleavespackages/spec/.examples-build/behind, andcheck:docs-audit-scopethen fails its own self-test in the same working tree #15446 records exactly this sequence —check:skill-examplesleavespackages/spec/.examples-build/behind and the docs-audit self-test then admits it — and the farm had runcheck:skill-examplesimmediately before. Duplicate-searched before concluding (control query answered spec: duration-shaped number keys carry their unit in describe prose only — twottlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478).Ablation — the gate measures something, and the refusals fire
Script kept in the session scratchpad; every leg confirmed on disk before its reading, restored with a trap and proved by blob hash (
5d7306265c5c…before and after) plusgit diff HEADempty:src/zz-ablation-offender.zod.ts,cooldown"in seconds"): 65 → 66 offenders, the injected site named; file removed → 65.--rooton scratch trees: a tree of three compliant keys (ttlMs,retentionDays, a{ value, unit }pair) → exit 0, "zero offenders"; the same tree plus onettl"in seconds" → exit 1, 1 offender.grep -c1 on the marker, 0 on the original anchor): self-test 7 cases red, production count 65 → 2 — the self-test is the instrument watching the rule, ascheck-self-test-wiredrequires.conversions.test.ts/migrations.test.ts.Fix lap — workspace type check restored (head
e68ae2b5)One file changed since
99999540a:packages/drivers/driver-turso/src/turso-driver-options-door.test.ts,timeouttotimeoutMs. Scope was the type check and nothing else — the gate's 65 remaining offenders are untouched andLint & Repo Gatesstays red by design.DriverOptionsoutsidenode_modules(73 of them) was read for the retired spelling, and separately every baretimeout:/ttl:key literal and every.timeoutproperty read underpackages/**,apps/**andexamples/**. One site: the turso door test. The empty results are part of the reading — memory, mongodb, sql and sqlite-wasm each carry their own door / conformance tests and none writes the renamed key;NoSQLQueryOptionsSchema.timeout,DataEngineExecuteRequest.options(a free-formz.record),LRUCache.ttl,lifecycle.ttl, the SMTP transporttimeoutandHealthCheckConfig.timeoutare all other schemas, and the survivingttlreaders already spellttlMs/ttlSeconds.pnpm --filter @objectstack/driver-turso typecheckon99999540areproduced the two CI errors verbatim —VERDICT command-exit 2.pnpm --filter @objectstack/driver-turso test—VERDICT command-exit 0,Test Files 44 passed (44),Tests 1159 passed (1159).e68ae2b5:pnpm exec turbo run typecheck --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'givesTasks: 135 successful, 135 total— 70 of themtypechecktasks, all five driver packages among them, zeroerror TS,VERDICT command-exit 0. That is the answer to "is a second driver hiding behind the first": turbo tore down 21 tasks after turso failed on99999540a, and here every one of them ran.*.test.ts.@objectstack/driver-tursopublishesfiles: ["dist","README.md","CHANGELOG.md"]built from asrc/index.tsentry, andcheck:published-filesre-confirms every publishable package "admits no test": the edit releases nothing, which is case 2 ofpr-automation.yml"WHICH LEVEL". No public surface of that package moves — the type that moved isDriverOptions, and@objectstack/specalready carries the BREAKING changeset for it. Theskip-changesetlabel is not the remedy either: that is for a PR releasing nothing at all, and this one releases plenty.e68ae2b5, each exit code captured by redirecting to a file before any pipe:check:nul-bytes0,check:cross-package-test-inputs0,check:test-source-alias0,check:type-source-resolution0,check:published-files0.check:duration-unit-keys1, printing✗ check:duration-unit-keys — 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s)— the same 65 as before this lap, so the held decision is untouched.packages/spec/src/**only, and the same offender shape exists outside it (measured:--root ../drivers/driver-turso/srcreports 1 offender,src/spec/turso.zod.ts:104). A second axis of the same open population question, so it is recorded rather than answered.🤖 Generated with Claude Code
https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Generated by Claude Code
Generated by Claude Code