Skip to content

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

Draft
claude[bot] wants to merge 11 commits into
mainfrom
claude/issue-14478-duration-unit-in-key-name
Draft

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
claude[bot] wants to merge 11 commits into
mainfrom
claude/issue-14478-duration-unit-in-key-name

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Part of #14478
Fixes #14519

Executes the maintainer ruling recorded on #14478 at comment 5518649320ruled B, verbatim 「14461 你不处理,其他同意」, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」: a spec-source gate for duration-shaped z.number() keys with no grandfathered baseline, plus an ADR-0087 conversion of every offender the ruling named, in one PR. Dispatched by the domain:spec seat (claim 5547289696), CONTRACT_REVIEW_TIER.

Clause-②: yes — seven published authorable keys are renamed. needs:contract-review is 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.tsexit 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.ts alone) exits 0. The other 62 files are ordinary.

The skills/** edit is kept on purpose and must not be dropped to escape governance: on origin/main that reference names timeout at :109-110 (an authored example, timeout: 5000,) and in the prose lists at :871 and :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 from hotlong (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.

⚠️ Read this first: the ruling's offender list is 7; the ruling's rule finds 70

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 — a z.number() chain whose .describe() names a time unit and whose key name carries none — finds 70 offenders on ca46f8f12 (six more were detector false positives, since removed: ordinal "second", min as 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):

class count examples
authored config durations — the ruling's class, unnamed by the ruling ~30 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.refreshInterval
keys whose spelling mirrors an external standard ~12 HttpCacheConfig.maxAge / staleWhileRevalidate / staleIfError (Cache-Control directives), CORS maxAge twice, better-auth expiresIn three times, S3 presign expiresIn, pg statementTimeout, DNS ttl, OAuth device-flow interval
runtime-emitted measurements, never authored ~16 StartupResult.duration / totalDuration, PluginHealth.uptime / responseTime, TraceSpan.duration, ApiError.retryAfter (the wire envelope)
instants — "Unix timestamp in milliseconds" 6 timestamp, lastSeen, startTime, registeredAt, createdAt

I 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.yml as the last step of the Lint & Repo Gates job 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.ts

pnpm --filter @objectstack/spec check:duration-unit-keys (self-test first, wired as check:self-test-wired requires; classified NO_GENERATOR in check-generated.ts; declared population packages/spec/src/** via the ROOT_DIR_WATCH_HINTS idiom, 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-inherited Millis), and the token must agree with the prosettlMs described "in seconds" is refused too. { value, unit } pairs are recognised structurally by the sibling unit key; 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" and min as minimum out.

Why packages/spec/scripts/ and not packages/lint: @objectstack/lint validates 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 sessionTimeout with no unit anywhere") the rule fired 44 times on ca46f8f12, 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 append Ms to a count. --list still 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

schema before → after route ADR-0087
HookSchema (hooks[]) timeouttimeoutMs retiredKey() tombstone on the strict shape (carries the rename; tsc never + parse); alias timeoutms → timeout removed D2 hook-timeout-to-timeout-ms (retired from the load path) + step 18
JobSchema (jobs[]) timeouttimeoutMs tombstone; alias timeoutMs → timeout removed; system/Job:timeout registered D2 job-timeout-to-timeout-ms + step 18
DriverOptionsSchema timeouttimeoutMs tombstone (non-strict); data/DriverOptions:timeout registered semantic driver-options-timeout-to-timeout-ms (a per-call options object has no stack seam)
MetadataManagerConfigSchema cache.ttlcache.ttlSeconds; cache.databaseLoader.ttlcache.databaseLoader.ttlMs two tombstones (non-strict nested objects) semantic metadata-manager-config-cache-ttl-unit-in-key
DatabaseLevelIsolationStrategySchema / TenantSecurityPolicySchema connectionPool.idleTimeoutidleTimeoutSeconds; accessControl.sessionTimeoutsessionTimeoutSeconds; describes now say "in seconds" two tombstones semantic tenant-timeouts-unit-in-key

Every 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 a tsc-channel case). Hook is not on the authorable surface (its handler is a function), so it has no RETIRED_KEYS_BY_MAJOR row; the two nested ttl keys 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 what content/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

reader before → after
packages/metadata/src/loaders/database-loader.ts (:136, :252) DatabaseLoaderCacheOptions.ttlttlMs; ttl: cacheOpts?.ttlMs ?? 60_000 (same magnitude)
packages/objectql/src/hook-wrappers.ts:358 meta.timeoutmeta.timeoutMs
packages/spec/src/contracts/job-service.ts JobScheduleOptions.timeouttimeoutMs — renamed in lockstep: a contract key that re-spelled the value without its unit would reintroduce one layer down exactly the ambiguity the rename removed
packages/runtime/src/app-plugin.ts:1098 threads { retryPolicy, timeoutMs: job.timeoutMs }
packages/services/service-job/src/run-with-policy.ts:127, db-job-adapter.ts options?.timeoutMs; withoutPolicy strips timeoutMs
examples/app-showcase/src/automation/jobs/index.ts:23 timeoutMs: 300000
packages/spec/src/data/hook.form.ts:71 form field timeoutMs (i18n bundles regenerated with node scripts/check-i18n-bundles.mjs --write)
liveness ledgers hook.json / job.json timeoutMs live rows with the same anchors; timeout rows kept as dead tombstone rows (the retiredKey route keeps the key in the walked shape)
docs: content/docs/automation/jobs.mdx, content/docs/protocol/kernel/metadata-service.mdx, packages/metadata/README.md:180 new spellings
skills/objectstack-data/references/data-hooks.md (governed) timeoutMs in the example and the two prose lists

The 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 transport timeout, 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.

CORRECTION — fix lap, 2026-09-05, head e68ae2b5. The list above originally also named turso timeout among the "different keys on different schemas". That was wrong. It is corrected in place rather than deleted, because the mistake names its own failure mode: turso carries two timeout spellings and the disambiguation collapsed them into one.

  • TursoDriverConfig.timeout (packages/drivers/driver-turso/src/turso-driver.ts:108) and its zod twin packages/drivers/driver-turso/src/spec/turso.zod.ts:104 — the driver's OWN connection config. It lives outside packages/spec/src/**, which is this gate's entire declared population (ROOT_DIR_WATCH_HINTS, held by the script's self-test). Genuinely a different key on a different schema, and still untouched — correctly.
  • packages/drivers/driver-turso/src/turso-driver-options-door.test.ts:119 — a literal typed by the Parameters lookup on argument 3 of TursoDriver.update. That argument is DriverOptions, so the key written there was exactly the one this PR renames. Missed.

The miss was an UNDER-collection — the mirror image of the over-collection the disambiguation was guarding against — and it is what turned Type Check · workspace red (run 33932953271, job 101215162163, head 99999540a): TS2322: Type 'number' is not assignable to type 'undefined' at :119, TS18048 at :121. Both are the retiredKey tombstone type doing precisely what it is for. Fixed by moving the literal to timeoutMs at the same magnitude — milliseconds in and out, no value conversion.

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.json carries no Tenant* row at all (the only tenant rows are cloud/ProvisionTenantRequest:*), so no RETIRED_KEYS_BY_MAJOR entry 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 as minor during the launch window; the BREAKING banner and the ADR-0087 disposition are the carriers) + pr-automation.yml "WHICH LEVEL" + precedent in packages/spec/CHANGELOG.md (**BREAKING** … shipped as minor):

  • @objectstack/spec minor, BREAKING banner, adr-0087: registered naming the five ids;
  • @objectstack/metadata minor, BREAKING (DatabaseLoaderOptions.cache.ttlttlMs is an exported interface member), adr-0087: registered metadata-manager-config-cache-ttl-unit-in-key (the gate refused already-registered for an id this diff adds — measured, corrected);
  • @objectstack/objectql, @objectstack/service-job, @objectstack/runtime patch — 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 package skills/objectstack-data/**/*.md: 3736 → 3736. All skills/**/SKILL.md: 6835 → 6835. Net zero; no re-wrap, no content bought.

Out-of-scope finding, filed (not ridden along)

Verification (final head 99999540a)

  • pnpm --filter @objectstack/spec buildVERDICT 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 typecheckVERDICT command-exit 0.
  • vitest, spec: hook, job, driver, tenant, metadata-loader, conversions/, migrations/, alias-integrity, retired-key-migrate-sentence, strict-object, hook-body, hook-form, check-generated-ledgerTest Files 16 passed, Tests 719 passed (after the last three job fixtures were renamed).
  • vitest, readers: metadata database-loader.test.ts 86 passed; objectql hook-binder + hook-metrics 32 passed; service-job db-job-adapter.timeout + cron-job-adapter + interval-job-adapter 40 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 (needs client-react built first — measured: a stale-dist refusal until then).
  • Gate families derived with 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-keys 1 (the 65 remaining offenders, by design); check:dual-build-cjs-loads 3 PREREQUISITE NOT MET (needs a full pnpm build; NOT MEASURED); check-engine-split-ratio --days 90 2 (shallow clone refusal; NOT MEASURED); check-required-contexts --verify-required-set 2 in the farm (HTTP 401 without the proxy) and 0 re-run as NODE_OPTIONS=--use-env-proxy; check:pm-dispatch-gates run detached per its own header → ✓ dispatch-gates self-test: 1445 cases pass. (exit code not captured — detached).
  • check:docs-audit-scope red 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-examples leaves packages/spec/.examples-build/ behind, and check:docs-audit-scope then fails its own self-test in the same working tree #15446 records exactly this sequence — check:skill-examples leaves packages/spec/.examples-build/ behind and the docs-audit self-test then admits it — and the farm had run check:skill-examples immediately before. Duplicate-searched before concluding (control query answered spec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, 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) plus git diff HEAD empty:

  • A — inject one offender (src/zz-ablation-offender.zod.ts, cooldown "in seconds"): 65 → 66 offenders, the injected site named; file removed → 65.
  • B — --root on scratch trees: a tree of three compliant keys (ttlMs, retentionDays, a { value, unit } pair) → exit 0, "zero offenders"; the same tree plus one ttl "in seconds" → exit 1, 1 offender.
  • C — mutate the detector (prose regex made unmatchable, grep -c 1 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, as check-self-test-wired requires.
  • ADR-0087 refusals: each old spelling is pinned to fail with the rename prescription (see the five test files), and the D2 fixtures replay in 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, timeout to timeoutMs. Scope was the type check and nothing else — the gate's 65 remaining offenders are untouched and Lint & Repo Gates stays red by design.

  • Swept by TYPE, not by name. Every file naming DriverOptions outside node_modules (73 of them) was read for the retired spelling, and separately every bare timeout: / ttl: key literal and every .timeout property read under packages/**, apps/** and examples/**. 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-form z.record), LRUCache.ttl, lifecycle.ttl, the SMTP transport timeout and HealthCheckConfig.timeout are all other schemas, and the surviving ttl readers already spell ttlMs / ttlSeconds.
  • Reproduced before fixing. pnpm --filter @objectstack/driver-turso typecheck on 99999540a reproduced the two CI errors verbatim — VERDICT command-exit 2.
  • Green after. The same command plus pnpm --filter @objectstack/driver-turso testVERDICT command-exit 0, Test Files 44 passed (44), Tests 1159 passed (1159).
  • The CI job's own command, re-run on e68ae2b5: pnpm exec turbo run typecheck --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' gives Tasks: 135 successful, 135 total — 70 of them typecheck tasks, all five driver packages among them, zero error 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 on 99999540a, and here every one of them ran.
  • No changeset owed — derived, not guessed. The one changed file is a *.test.ts. @objectstack/driver-turso publishes files: ["dist","README.md","CHANGELOG.md"] built from a src/index.ts entry, and check:published-files re-confirms every publishable package "admits no test": the edit releases nothing, which is case 2 of pr-automation.yml "WHICH LEVEL". No public surface of that package moves — the type that moved is DriverOptions, and @objectstack/spec already carries the BREAKING changeset for it. The skip-changeset label is not the remedy either: that is for a PR releasing nothing at all, and this one releases plenty.
  • Gates re-run on e68ae2b5, each exit code captured by redirecting to a file before any pipe: check:nul-bytes 0, check:cross-package-test-inputs 0, check:test-source-alias 0, check:type-source-resolution 0, check:published-files 0. check:duration-unit-keys 1, 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.
  • Filed, not ridden along: finding: check:duration-unit-keys scans packages/spec/src/** only — the same offender shape exists in workspace packages the gate never reads #15642 — the gate's declared population is packages/spec/src/** only, and the same offender shape exists outside it (measured: --root ../drivers/driver-turso/src reports 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

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/metadata, @objectstack/objectql, @objectstack/platform-objects, @objectstack/runtime, @objectstack/service-job, @objectstack/spec, touching 39 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/metadata/README.md, packages/objectql/src/hook-binder.ts, packages/spec/authorable-surface/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b25a5fc32d784fb42fbac342486b5579997e088c.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 9 changed file(s) yielded no anchor (packages/metadata/README.md, packages/objectql/src/hook-binder.ts, packages/spec/authorable-surface/data.json, …) — pages documenting those are invisible to this run
  • 15 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 — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b25a5fc32d784fb42fbac342486b5579997e088cpackageMentionDocs.

Which tree this was computed on

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

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cebbc601c244cb605afadb8c8d0447dfa4bdbc1b && git checkout cebbc601c244cb605afadb8c8d0447dfa4bdbc1b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b25a5fc32d784fb42fbac342486b5579997e088c 140e0b266578c1325d96d95294513232520c72ae && git checkout -B drift-repro b25a5fc32d784fb42fbac342486b5579997e088c && git merge --no-ff 140e0b266578c1325d96d95294513232520c72ae

node scripts/docs-audit/affected-docs.mjs --json b25a5fc32d784fb42fbac342486b5579997e088c

⚠️ 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 b25a5fc32d784fb42fbac342486b5579997e088c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

`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
)

`packages/spec/liveness/state-counts.md` is routed to `merge=os-regen`, so the
merge of origin/main took it without a text merge and left it owing a
regeneration. Regenerated with `pnpm --filter @objectstack/spec
gen:liveness-counts` from the merged tree, which is the union of both sides:
main's `field` row (89 live / 3 planned -> 90 live / 2 planned) lands on top of
this branch's `hook` and `job` rows, and the totals follow.

No hand edits: the file is generator output.

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 Author

Conflict lap done — head 140e0b266, conflict cleared. And the gate count is UNCHANGED at 65, which is the number the stack needed.

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T07:29Z. Follows the announcement at 5550141522.

The conflict is gone, verified the only way that discriminates

reading before after
git -c merge.os-regen.driver=false merge-tree --write-tree vs main exit 1, CONFLICT (content): … state-counts.md exit 0, tree 897994436
driver-enabled form, same pair exit 0 (the false clean) exit 0, byte-identical tree 897994436

⭐ The two readings now agree, which they did not before — that agreement is itself the proof the conflict is actually gone rather than merely absorbed. GitHub concurs: mergeable=true, and mergeable_state moved dirtyblocked (required checks, not conflict).

How it was done: scripts/pm/os-regen-merge.sh, the in-repo sanctioned sequence. Merge commit 1791be418 (ordinary merge — ⛔ no rebase, no amend, no force-push; the push was the fast-forward e68ae2b58..140e0b266). The conflict was cleared by regeneration, never by hand: pnpm --filter @objectstack/spec gen:liveness-counts on the merged tree, as its own commit. The pre-commit hook refused the first attempt (state-counts.md - stale) and then printed all deferred artifacts are current - marker cleared.

Scope proof, stronger than a file count: git diff-tree --cc 1791be418 is empty — the merge commit carries no content differing from both parents, so every other path took one side verbatim — and git diff --stat 1791be418 140e0b266 is exactly packages/spec/liveness/state-counts.md | 4 ++--. The regenerated table is the true union: main's field row (89 live / 3 planned → 90 / 2) over the branch's hook and job rows; totals 845/5/1/92/12/955.

⛔ Untouched, as fenced: the gate and its offenders, the stack cards, the draft flag, labels, reviewers, auto-merge, the queue, and both the Part of #14478 and Fixes #14519 lines (body never edited). content/docs/releases/ unchanged. Gates run on the pushed head: check:liveness exit 0 ("state-counts.md is current — the same 36 row(s)"), check:generated exit 0 ("All 15 generated artifacts are up to date"), check:merge-driver exit 0, check:nul-bytes exit 0.

⭐ The reading the stack actually needs: the merge did NOT move the target

Lint & Repo Gates on 140e0b266, verdict line verbatim:

✗ check:duration-unit-keys — 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s)

65 / 215 / 762 — all three identical to the pre-merge reading, despite 63 main commits coming in. ⇒ packages/spec/src/** gained no new duration keys, so #15677#15682 keep the radius they were cut against. That was not safe to assume — it is measured.

Board on this head: 30 green · 2 skipped · 1 running · 1 red, and the one red is check:duration-unit-keys alone (ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL; its --self-test passes first, and nothing else in the job failed).

⚠️ What remains, and it is not something a lap can fix

Lint & Repo Gates is one of 7 required contexts on main. So this PR is still unmergeable, and no amount of conflict work changes that: the gate goes green only when #15676 declares the two exemption classes (ruled 5548763981) and #15677#15682 convert the remainder. ⚠️ And if this PR ever landed alone, that red required context would block every PR in the repository — the queue is healthy and moving (8 merges 06:06–06:54Z).

This PR and its stack have to reach main as one green whole. ⛔ Not my call — flagging it so it is decided deliberately rather than discovered at the queue.


Generated by Claude Code

… the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15814)

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

⛔ A scheduled action this PR needs before it lands on main — one changeset line, and it cannot be done earlier

Surfaced by card 6/6's verification and confirmed against the gate's own rule. Recording it here because this is the PR that will hit it, and it would otherwise be discovered at the worst possible moment: a required context going red on the landing itself.

What will happen

check:adr-0087-registration runs in Lint & Repo Gates — a required context on main. Its rule for the already-registered disposition is stated in its own header, verbatim:

already-registered — every named id must resolve at HEAD and already exist at the merge base. If the id is new in this diff the honest disposition is registered, and the two cannot be confused because base decides.

Card 6/6's changeset (.changeset/driver-turso-config-timeout-ms.md) carries:

adr-0087: not-required (already-registered turso-config-timeout-to-timeout-ms)

That id is registered by card 5/6's changeset. So:

base turso-config-timeout-to-timeout-ms exists there? verdict
card 6/6's own base (card 5/6's head) yes ✅ exit 0 — the disposition is honest
origin/main no R5 fires (severity 3) — "already-registered pointing at an id this diff just added"

When this branch merges to main, the combined diff carries both changesets. Relative to main the id is new in the diff, so card 6/6's disposition becomes dishonest at that level and R5 reds.

⚠️ Why it cannot be fixed on card 6/6

Changing it to registered there would break it against its own base: R4 refuses registered claiming a registration this PR did not make, and card 6/6's diff does not add the id — card 5/6's does.

The two base levels demand contradictory dispositions. That is the actual finding: the disposition is base-relative, and a stacked card has two bases. Neither card is wrong; the gate has no vocabulary for "already registered by a sibling in the same eventual diff". Its R5 rule consults only the ids this diff adds, with no cross-reference to a sibling changeset carrying registered.

The action, and when

After all six cards are merged into claude/issue-14478-duration-unit-in-key-name, and before this PR is merged to main: on the base branch, change card 6/6's changeset disposition from

adr-0087: not-required (already-registered turso-config-timeout-to-timeout-ms)

to registered naming that id — which is then the honest reading, because relative to main the combined diff genuinely does register it.

⛔ Do not do this earlier. While the cards are still separate PRs, already-registered is the correct disposition on card 6/6 and changing it reds that card.

Verify after the edit by running the gate the way the landing will: node scripts/check-adr-0087-registration.mjs --base origin/main from the base branch, expecting exit 0.

Not a defect in any card

Both cards' dispositions are honest at their own level, and card 6/6's verification measured this rather than tripping over it. It is a gap in the gate's vocabulary for stacked work, worth a finding card of its own once the stack is out of the way — filing one now would only compete with the landing it describes.


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The landing is now simulated end to end — the five merges are clean, and the scheduled changeset edit is pre-verified with its exact text

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T21:20Z. Follows comment 5554574094, which scheduled a one-line edit on this branch and could only predict it. It is now measured, and two traps in it were found the hard way — recorded here so the edit is a known-good one rather than a hoped-for one.

Method: a throwaway detached worktree at this branch's tip c09fd1023, merging the five open cards into it in landing order, then running the gate on the result. Simulation only — nothing pushed.

1. All five merges are clean, and the two readings agree

trunk c09fd1023
  ← 2/6 #15837   exit 0   0 conflicts   0 driver deferrals
  ← 3/6 #15906   exit 0   0 conflicts   0 driver deferrals
  ← 4/6 #15938   exit 0   0 conflicts   0 driver deferrals
  ← 5/6 #15988   exit 0   0 conflicts   0 driver deferrals
  ← 6/6 #16022   exit 0   0 conflicts   0 driver deferrals

Re-run from the same starting point under -c merge.os-regen.driver=false — what a clone with no driver registered sees, and therefore what GitHub does — the five merges are again all clean and the final tree is 054a7c4c4be2c9c8cc98fe0ea1e562c216774f1c in both runs, byte for byte.

⭐ That agreement is the point. A driver-enabled merge understates (it can resolve a generated path and defer the regeneration, invisibly); a clone without the driver text-merges. When the two produce the same tree, nothing was absorbed. merge.os-regen.driver is registered in the clone that produced the first run — verified — so the agreement is real rather than an artifact of an unconfigured clone.

2. ⛔ But merge them ONE AT A TIME, in order — a level-skipping merge does conflict

Because every card branched from an older tip of the one below it, 6/6's head does not contain the whole chain. Merging it into this branch directly, skipping the four cards between, is not the same operation:

git merge-tree --write-tree c09fd1023 0ae5a8aa5   → exit 1
CONFLICT (content): packages/spec/scripts/check-duration-unit-keys.ts
CONFLICT (content): packages/spec/src/migrations/registry.ts

Those two files are the ones every card touches, which is exactly why the per-card merges resolve them incrementally and a level-skipping merge does not. Merge order: 2/6 → 3/6 → 4/6 → 5/6 → 6/6.

3. The two count gates clear at this branch, confirmed on the real merged tree

Not a merge-tree projection this time — the reading is taken from the simulated landed tree itself:

quick-reference.mdx:209 llms.txt shared row llms.txt heading
the chain's cards (5 of 8 schemas) | shared | 13 | (207 schemas)
landed tree 054a7c4c4 (5 of 9 schemas) | shared | 14 | (208 schemas)

Card 1/6's count fix reached this branch after the chain forked, so check:llms-txt and check:quick-reference-counts are red on every card and green the moment the card lands here. ⛔ Nothing to repair on any card.

4. ⭐ The scheduled edit, pre-verified — with the exact text and two traps

Run on the simulated landed tree, node scripts/check-adr-0087-registration.mjs --base origin/main exits 1, exactly as predicted, and the gate itself dictates the replacement:

• .changeset/driver-turso-config-timeout-ms.md
    `already-registered` names only id(s) this very diff ADDS: turso-config-timeout-to-timeout-ms
    "already" means it existed at the merge base. If this PR made the registration, the
    honest disposition is:  <!-- adr-0087: registered turso-config-timeout-to-timeout-ms -->

Applying that and re-running: exit 0, ✓ check-adr-0087-registration: 9 declared-breaking changeset(s), each carrying an ADR-0087 disposition.

Two things that will waste an hour if they are not known in advance:

  • ⚠️ The gate reads the changeset from the committed diff, not the working tree. An uncommitted edit changes the verdict not at all — the same finding comes back word for word. Commit before re-running, or you will conclude the fix does not work.
  • Trailing prose after the id is parsed as more ids. Keeping the current explanatory sentence inside the marker produced 29 of those id(s) do not exist in the ADR-0087 registries at HEAD: —, that, protocol-18, conversion, added, by, card, …. The not-required (…) form tolerates it because the parentheses delimit the ids; registered has no delimiter.

Both accepted spellings, each measured at exit 0:

<!-- adr-0087: registered turso-config-timeout-to-timeout-ms -->

or, keeping the explanation, as a second comment on its own line:

<!-- adr-0087: registered turso-config-timeout-to-timeout-ms -->
<!-- The protocol-18 conversion above is registered by card 5/6 of the #14478 stack; relative to
     `main` this combined diff is what adds it, so `registered` is the honest disposition at this
     level even though `already-registered` was honest per-card. -->

⛔ Still not to be done earlier: while the cards are separate PRs, already-registered is correct on card 6/6 and registered fires R4 there.

5. One reading I could not take, said plainly

check:duration-unit-keys was not executed on the landed tree — the simulation worktree carries no install, and two attempts returned ERR_MODULE_NOT_FOUND, which is a non-reading and not a red. What supports it instead: the gate is green on card 6/6's head (217 keys across 2291 source files, zero offenders), 6/6 moves the population but leaves the predicate untouched, and every file in the old population was judged by that same predicate on the card that introduced it. Of the five files the remaining merges add inside the population, two are JSDoc @example comment edits (timestampoccurredAt / checkedAt), one is a *.test.ts the walk excludes, and the two migration-ledger files change only prose inside string literals. So the census cannot move — but the executed reading on this tree belongs to CI, which is the first place it can run.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) PASS at head c09fd102 — director seat, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T23:0xZ. Verdict (the no-baseline gate, the seven ruled conversions with their D2/semantic split, readers at the same magnitude, the population supersession by ruling 5548763981 and the stack that turns the gate green, the landing procedure incl. the scheduled changeset edit): objectstack#14478 comment 5555247317. needs:contract-review removed from this PR and the card in the same stroke.

Governed (skills/objectstack-data/references/data-hooks.md, skills/objectstack-api/references/_index.md — 2 of 116 paths): draft is the finished state, a human merges. Assembly order 2/6 → 3/6 → 4/6 → 5/6 → 6/6 one at a time, the registered turso-config-timeout-to-timeout-ms changeset edit committed on this branch, then origin/main merged in with the os-regen sequence, then the human merge. All five stack cards now carry a tier PASS (2/6 5555227297, 3/6 5553081426, 4/6 5555234121, 5/6 pending on #15680, 6/6 5555240077).

Implemented-by: session_01G4138K1EG7kQ81FNba5Kp4
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

⛔ Correction to my own landing simulation: this repo squash-merges, and my five-merge dry run modelled the wrong landing mode

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T22:52Z. Corrects comment 5554859483.

What I got wrong

That simulation merged the five cards into this branch with git merge --no-ff — real merge commits — and reported all five clean. The repository does not land PRs that way. Card 2/6 (#15837) merged at 22:40Z as commit 43cc4659a, single parent, GitHub's squash form. Card 1/6 landed the same way at c09fd1023.

Measured, with a control so the zero is a reading rather than a dead query: 0 merge commits in main's last 154 commits, while the same --merges query on this branch does find 1791be418. Squash is not a choice anyone made on the day — it is the only button this repository offers.

⇒ 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 branch

A squash keeps the content and destroys the ancestry link, so the next card's merge base rewinds past everything already landed:

git merge-base --is-ancestor dcd1be0db 43cc4659a   → exit 1   (2/6's head is NOT in this branch's history)
git merge-base 43cc4659a adad660a1                 → 140e0b266  (before cards 1/6 AND 2/6)

Card 3/6 therefore re-applies content this branch already holds, and collides. Measured both ways:

reading result
git merge-tree --write-tree 43cc4659a adad660a1 exit 1 — 1 conflict: packages/spec/src/migrations/registry.ts
the same under -c merge.os-regen.driver=false exit 1 — that one plus content/docs/references/kernel/startup-orchestrator.mdx and packages/spec/authorable-surface/kernel.json

The two extra are merge=os-regen paths owing a regeneration, not a text collision — that flag replaces the driver command with the shell builtin false, so every driver-managed path touched on both sides reports CONFLICT regardless. The single real conflict is the ledger, and its shape is benign: in all three hunks the trunk side is empty and card 3/6's RETIRED_KEYS_BY_MAJOR[18] rows sit on the other — an insertion-point collision where the resolution is to keep both sides' rows.

A repair lap for #15906 is dispatched, carrying the acceptance test that matters: card 2/6's twelve api/… rows must still be there afterwards, counted rather than eyeballed. A resolution that silently drops them would compile and pass most gates.

What this changes for the rest of the landing

One 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 unchanged

Those results were about content, not merge mode, and they still hold:

  • the two count gates go green the moment a card's content reaches this branch — now confirmed for real, since 43cc4659a carries 5 of 9 schemas / | shared | 14 | / 208 schemas;
  • the scheduled adr-0087 changeset edit is still pre-verified with its exact text and its two traps (comment 5554859483, section 4). ⛔ Still to be applied only after all six cards are here.

Generated by Claude Code

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up from the domain:spec seat for the #14478 stack (session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-05T22:51Z): PR #15973 (the #15513 + #14477 compliance-families retirement fold) MERGED at 2026-09-05T22:51:10Z as c463d03e0. It adds 3 entry files under packages/spec/src/migrations/entries/ and the matching add-only rows in packages/spec/src/migrations/registry.ts (RETIRED_DEFS_BY_MAJOR[18] ×19, RETIRED_KEYS_BY_MAJOR[18] ×2, four semantic entries) — the hot file this stack also edits. Merge main into the stack branches via bash scripts/pm/os-regen-merge.sh (never gen:schema in a MERGE state); the seat's earlier read of the two heads showed the fold's rows sit beside, not on, the stack's duration-unit entries, so a text merge should be conflict-free. Entry files added: 18.system__ChangeType.ts, 18.system__Incident.ts, 18.training-family-retired.ts.


Generated by Claude Code

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the heads-up above (2026-09-05T22:52Z): the squash c463d03e0 adds 25 entry files under packages/spec/src/migrations/entries/ (not 3 — the earlier count came from a width-truncated --stat listing) and registry.ts moves +737/−1 (add-only rows plus one re-joined narrative sentence). The files: retired-defs/18.system__ChangeImpact.ts, retired-defs/18.system__ChangePriority.ts, retired-defs/18.system__ChangeRequest.ts, retired-defs/18.system__ChangeStatus.ts, retired-defs/18.system__ChangeType.ts, retired-defs/18.system__Incident.ts, retired-defs/18.system__IncidentCategory.ts, retired-defs/18.system__IncidentNotificationMatrix.ts, retired-defs/18.system__IncidentNotificationRule.ts, retired-defs/18.system__IncidentResponsePhase.ts, retired-defs/18.system__IncidentResponsePolicy.ts, retired-defs/18.system__IncidentSeverity.ts, retired-defs/18.system__IncidentStatus.ts, retired-defs/18.system__RollbackPlan.ts, retired-defs/18.system__TrainingCategory.ts, retired-defs/18.system__TrainingCompletionStatus.ts, retired-defs/18.system__TrainingCourse.ts, retired-defs/18.system__TrainingPlan.ts, retired-defs/18.system__TrainingRecord.ts, retired-keys/18.data__ESignatureConfig__expirationDays.ts, retired-keys/18.data__ESignatureConfig__reminderDays.ts, semantic/18.change-management-family-retired.ts, semantic/18.esignature-config-deadline-keys-retired.ts, semantic/18.incident-response-family-retired.ts, semantic/18.training-family-retired.ts.


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Measured the fold against this branch — the ledger does conflict, and the reason is the same one that just bit card 3/6

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T22:56Z. Following the domain:spec seat's heads-up about #15973 landing on main as c463d03e0 (verified: it is main's tip, and it adds 55 rows to packages/spec/src/migrations/registry.ts).

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:

git merge-tree --write-tree origin/main <this branch>   → exit 1, 3 REAL conflicts
  packages/spec/llms.txt
  packages/spec/src/migrations/registry.ts
  packages/spec/src/type-alias-convention.pin.test.ts

Under -c merge.os-regen.driver=false the same command lists 12; the other nine are merge=os-regen paths owing a regeneration (authorable-surface/*.json, declaration-map, export-origins, json-schema.manifest, the reference pages), not text collisions — that flag substitutes the shell builtin false for the driver, so every driver-managed path touched on both sides reports CONFLICT regardless. The three above are the ones a human has to look at.

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 on

This is the main → branch lap, which belongs after all six cards are here, not before — merging main in now would make every remaining card's repair lap larger. Recording it so the shape is known when the lap is scheduled: three text resolutions plus a regeneration pass through scripts/pm/os-regen-merge.sh, ⛔ never gen:schema in a MERGE state.


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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: tenant.zod.ts idleTimeout / sessionTimeout publish a describe with no unit, while the JSDoc one line above says seconds

4 participants