Repository navigation
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
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
|
The `dimensionless` schema marker landed with its gate reader and no docs half. Ruling B on #14478 put both halves in the mechanism: a marker "declared ON THE SCHEMA, never in a gate ledger … the gate honours and the docs generator publishes". Its sibling `externalVocabulary` has had its renderer since #15626; this one had none, so a key marked `dimensionless` would carry an in-schema declaration the published reference page says nothing about. Adds `dimensionlessNote()` beside `externalVocabularyNote()` in `scripts/lib/schema-section.ts`, appended to the same description cell, and reading the same declaration the gate reads: a non-empty string literal, so an empty or non-string marker publishes nothing. No key is marked by this change — the live population stays 0 — so no reference page moves and no published payload changes. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…bjectstack-ai#18684) Fixes objectstack-ai#18500 Clause-②: no PR objectstack-ai#18486 landed the `dimensionless` schema marker with its **gate reader** and no **docs half**. Ruling B on objectstack-ai#14478 put both halves in the mechanism, verbatim: > declared ON THE SCHEMA, never in a gate ledger … marker the gate honours **and the docs generator publishes** The sibling marker `externalVocabulary` has had its renderer in `packages/spec/scripts/lib/schema-section.ts` since objectstack-ai#15626. `dimensionless` had none, so a key marked `dimensionless` would carry an in-schema declaration that the published reference page says nothing about. The gate's own header already asserts the half that did not exist. `check-duration-unit-keys.ts`, exemption class 4: *"Same mechanism as class 2, same literal-only validation, **same visibility**."* Class 2 is `externalVocabulary`, and its visibility **is** that renderer. This PR is what makes that sentence true. ## What landed - `dimensionlessNote(prop)` in `packages/spec/scripts/lib/schema-section.ts`, beside `externalVocabularyNote(prop)`, appended to the same description cell rather than given a column. - Six pins in `packages/spec/scripts/schema-section.test.ts`, mirroring the sibling's block one-for-one. A key described `Failures seen in the last 5 minutes` and carrying `.meta({ dimensionless: 'failed attempts' })` now publishes: ``` Failures seen in the last 5 minutes (dimensionless — counts failed attempts) ``` ### Why that wording The card suggested "counts `what`" and wrote "⛔ no ruling implied", so the shape was read off the declaration instead of taken from the suggestion. The marker's declared value is a **non-empty string literal naming what the number counts** — `check-duration-unit-keys.ts` refuses an empty string and a computed value, because an unverifiable claim exempts nothing — so the declared value is printed verbatim, and the same non-empty-string-literal test decides whether anything is printed at all. The word `dimensionless` is kept in front of it for the one thing the value alone does not state: the exemption's claim is that the number has **no unit**, which is exactly the ambiguity a reader has when the prose beside a naked number names a time unit. It also keeps one vocabulary across the marker name, the census line the gate prints, and the ruling. ## The three questions the dispatch asked **1. Which gate can actually go red on this change?** `packages/spec/scripts/schema-section.test.ts`, run by `pnpm --filter @objectstack/spec test` (vitest project `local`; the file is not listed in `vitest.repo-tests.json`) — in CI, `Test Core`. `check:docs` and `check:generated` structurally cannot move: the live population is **0** keys marked, so no reference page changes and `check:docs` is green both before and after. Established by ablation, not assumed — below. **2. Does the marker ride into the published JSON Schema via `z.toJSONSchema`?** — **Yes.** - Direct, on the zod this workspace installs (4.4.3): `z.number().meta({ dimensionless: 'failed attempts' })` emits `"dimensionless": "failed attempts"` on the node, unchanged. A fabricated marker name rides identically, so the channel is generic rather than per-key. - On the real generated tree, after `pnpm --filter @objectstack/spec gen:schema`: 18 files under `packages/spec/json-schema/` carry `"externalVocabulary"` (LIT control), **0** carry `"dimensionless"`, **0** carry the fabricated name (DARK controls). `packages/spec/json-schema/` has 0 tracked files, so this cannot be read from git — it was built and read. ⇒ **the docs generator is not the only publisher.** `json-schema` is in `packages/spec/package.json` `files[]`, so a marked key would also ship inside the npm tarball. Nothing is marked by this PR, so nothing moves today — but the *second* publisher is a fact the first card to mark a key inherits. **3. What should it print?** — answered from the declared value shape, above. ## Clause-② and the changeset Declared `no`, unchanged — the dispatch reserved that line, and question 2's answer does not move it for **this** diff: no key is marked, so no published byte changes. `skip-changeset`, measured rather than assumed: - `packages/spec/package.json` `files[]` = `dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`. `scripts/**` is not among them. - `dimensionlessNote` after the build: **0** hits in every one of those paths. Positive control on the same greps, `ObjectSchema`: `api-surface` 2, `llms.txt` 1, `README.md` 1, `src/**/*.zod.ts` 14. - `dist` cannot carry it either way: every `tsup` entry in `packages/spec/tsup.config.ts` is under `src/`, so `scripts/**` is not an entry. - `check:docs` green ⇒ **0** reference pages move.⚠️ For the seat that marks the first key: at that moment the marker reaches **both** publishers, and `json-schema/` is published — so that card's declaration and changeset are a different question from this one's. ## Testing Union run at `d6f2148892` (the final commit; gate logs carry no sha, so this is the tree every number below was taken on). | run | result | |---|---| | `pnpm --filter @objectstack/spec test` (project `local`) | 484 files passed / 1 skipped, **13872 passed** / 1 skipped | | `pnpm --filter @objectstack/spec typecheck` (incl. `check:scripts-typecheck`, `check:test-typecheck`) | exit 0 | | `pnpm --filter @objectstack/spec check:docs` · `check:variant-docs` · `check:liveness` · `check:empty-state` · `check:strictness-ledger` | exit 0 each | | `pnpm lint` (`eslint . --no-inline-config`, whole repo — not narrowed) | exit 0 | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...` | **57 derived, 52 run, 5 NOT MEASURED, 0 UNRUN** | The 5 NOT MEASURED are `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt` — each exited **3**, `PREREQUISITE NOT MET`, which those gates spell out as "not a pass and not a finding". All five read built output of the whole monorepo, which this worktree does not have; CI's `Build Core` is where they are measured. This diff adds no export, no entry point and no emitted file, so it has no channel to reach any of them. ### Red before green — ablation with an on-disk proof and a hash-verified restore Run from the committed state, under a `trap ... EXIT INT TERM`, against absolute paths: 1. `HEAD:packages/spec/scripts/lib/schema-section.ts` blob = `a92ba895b1f51e8eaf8945e45f8bf8c12ea77e90`. 2. Anchor count before mutation: **1**. Mutation deletes the `dimensionlessNote(prop)` term from the description cell. 3. **Landed on disk**, both directions: deleted text **0** occurrences after, injected marker **1** occurrence after; mutated blob `4f00017eab...` differs from the HEAD blob. 4. Red leg: `Tests 4 failed | 39 passed (43)` — the four cases that assert the note is printed. The two that assert its **absence** stayed green, which is the predicted direction and what keeps the note from decorating every row in the reference. 5. Restore leg: `git checkout HEAD -- path` (never a bare `git checkout --`), then hash re-read = `a92ba895b1...`, matching, **and** `git diff HEAD` empty. Green re-run at the final head: 43 passed. Predicted direction was stated before the run and matched: turn red, not "more diagnostics" and not "inverted". ## `skills/**` derivative **None.** Reading, not expectation: the diff is exactly 2 files (`git diff --name-only` against the merge base), **0** of them under `skills/`. `packages/spec/scripts/build-skill-docs.ts` does not import `schema-section`, and `check:skill-docs` / `check:skill-refs` were not derived for these paths. So no net-line-count reading is owed. ## Acceptance notes Out of scope, noted and deliberately not filed: - **The `.meta()` channel is generic, not a marker allowlist.** A fabricated key name rides `z.toJSONSchema` into `json-schema/` exactly as `externalVocabulary` and `dimensionless` do, and `json-schema/` is published. This is the documented mechanism (`xRef` / `xExpression` / `xEnumDeprecated` use the same channel by design), not a defect, and none of the three filing classes fits: no repro, no contract violated, and the hazard is metadata being *published* rather than silently dropped or refused. Who will meet this file next: objectstack-ai#18124 step 4, the card that will first mark keys. - **Nothing mechanically ties "a marker the gate reads" to "a renderer in `schema-section.ts`".** The next marker added to `check-duration-unit-keys.ts` can repeat exactly this half-build. Not filed: it is undemonstrated drift today, ruling B is honoured on the tree as of this PR, and both markers that exist now carry both halves. ## Dispatch fences observed `packages/spec/src/migrations/**` and `scripts/check-cross-package-test-inputs.mjs` untouched (the latter moved on `main` under objectstack-ai#18667 and arrived through a plain merge of `origin/main`, with no conflict and no edit by this branch). `packages/spec/scripts/check-duration-unit-keys.ts` read only. No live key was marked `dimensionless` — population stays 0, by measurement above. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
…stem-* migration entries states each lesson in words, not tracker numbers (stage 3) (objectstack-ai#20384) Part of objectstack-ai#20233 Clause-②: no **Stage 3 of a staged card.** The card stays open for later stages; this PR carries no closing keyword. Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic moves, and the chain rewrites exactly what it rewrote before. ## What this does `os migrate meta` prints every ADR-0087 semantic entry it crosses as one block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's `reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's runtime-string rule applies to all of it: 「Runtime strings — refusal prose, prescriptions, anything an author is shown — carry no tracker number (`pnpm check:doc-authoring`): the lesson goes into the text.」 Form **D** of ruling C+D on the parent card sets the shape: the lesson in words, and no number, dead or alive. This stage covers the next three families by site count, `driver-`, `kernel-` and `system-`: **132 sites → 0** in the three prose fields. None of the 26 entries carries a tracker id in `surface` (ruling A of the stage-1 ACCEPT, `5858839916`, is checked and has nothing to do here). Each site now says what the cited ruling, measurement or fix decided. ADR ids stay. `registry.ts`, `spec-changes.json` and `docs/protocol-upgrade-guide.md` are regenerated from the entries (`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`), never hand-edited. The stage-1 pin now holds `engine-`, `ui-`, `plugin-`, `driver-`, `kernel-` and `system-`. ## Census — tracker ids in the author-shown fields **Instrument.** The stage-2 AST instrument, unchanged: a TypeScript-AST walk over every `packages/spec/src/migrations/entries/**/*.ts`. For each `entry` object literal it evaluates the string value of `replacement`, `reason`, `acceptanceCriteria` and (counted separately) `surface`, joining string literals with `+`, then counts `#` followed by 4 or 5 digits at a word boundary. **Validated first** by reproducing the stage-1 readings on the stage-1 tree (`443b2f4fdc`, extracted with `git archive`): `driver-` 7 entries / 44 sites (0 / 44 / 0, 25 distinct), `kernel-` 9 / 44 (1 / 41 / 2, 11 distinct), `system-` 10 / 44 (0 / 42 / 2, 6 distinct), `engine-` 5 / 67, whole tree 266 entries / 1,016 sites / 9 `surface` sites — every figure equal to the stage-1 census. **Tree measured:** `objectstack-ai/objectstack` at `569d4d2dbf` (this branch's base). Unevaluable fields: 0. **Controls, same run.** - **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites (replacement 1, reason 6), the reading stages 1 and 2 took. - **Dark (comment lines):** 794 `//` lines in entry files carry a tracker id, and none is counted. Comment lines belong to the sibling card, and ⛔ this PR touches none (794 before and after). - **Dark (field boundary):** the 7 `surface` sites left in the tree (other families) count 0 in the three-field total and 7 in the `surface` column. **Re-measured on the base, matching the stage-1 census:** `driver-` 7 entries, **44** sites (replacement 0 / reason 44 / acceptanceCriteria 0), 25 distinct ids; `kernel-` 9 entries, **44** (1 / 41 / 2), 11 distinct; `system-` 10 entries, **44** (0 / 42 / 2), 6 distinct. 37 distinct ids across the three (the families share `objectstack-ai#14478`, `objectstack-ai#15939`, `objectstack-ai#17635` and `objectstack-ai#3733`). `surface`: 0 in all three. Whole tree: 300 entries, **843** sites, 7 `surface` sites. **After this PR:** `driver-` 0, `kernel-` 0, `system-` 0; `engine-`, `ui-`, `plugin-` still 0; whole tree **843 → 711** sites; `surface` 7 (unchanged, other families). | entry | sites (replacement / reason / acceptanceCriteria) | |---|---| | `17.driver-aggregate-undeclared-key-aliases-removed` | 6 (0 / 6 / 0) | | `17.driver-capabilities-inert-bits-removed` | 4 (0 / 4 / 0) | | `18.driver-options-timeout-to-timeout-ms` | 1 (0 / 1 / 0) | | `17.driver-sql-distinct-bare-filter-typed` | 9 (0 / 9 / 0) | | `18.driver-sql-unresolvable-where-column-refused` | 14 (0 / 14 / 0) | | `18.driver-sql-upsert-cross-row-identity-merge-refused` | 9 (0 / 9 / 0) | | `18.driver-turso-config-local-path-wasm-retired` | 1 (0 / 1 / 0) | | `18.kernel-compatibility-matrix-estimated-migration-time-unit-in-key` | 5 (0 / 5 / 0) | | `18.kernel-context-preview-mode-retired` | 5 (1 / 4 / 0) | | `18.kernel-event-bus-retention-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-health-check-and-hot-reload-durations-unit-in-key` | 7 (0 / 5 / 2) | | `18.kernel-package-lifecycle-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-plugin-health-report-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-plugin-security-durations-unit-in-key` | 4 (0 / 4 / 0) | | `18.kernel-runtime-config-timeout-unit-in-key` | 11 (0 / 11 / 0) | | `18.kernel-startup-orchestrator-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-cache-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-collaboration-durations-unit-in-key` | 4 (0 / 4 / 0) | | `18.system-failover-health-check-interval-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-metrics-jsdoc-durations-unit-in-key` | 12 (0 / 12 / 0) | | `18.system-metrics-window-durations-unit-in-key` | 5 (0 / 3 / 2) | | `18.system-object-storage-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-registry-config-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-tracing-otel-exporter-durations-unit-in-key` | 5 (0 / 5 / 0) | | `18.system-tracing-span-duration-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-worker-queue-rate-limit-duration-unit-in-key` | 3 (0 / 3 / 0) | | **total, 26 entries** | **132 (1 / 127 / 4)** | ## Every citation read, and what the text now says I read each cited issue or PR myself with single-card REST reads: the body, and the comments where a ruling or a measurement lives. Ids are in code spans so this body posts no cross-references. All 36 bare ids were resolved against this repository, because every sentence that cites one is about this repository's code; the one cross-repo id is `cloud#1651`. | cited | what it decided (read) | how the text now carries it | |---|---|---| | `objectstack-ai#3733` | The pruned `cached` field key: measured, the parse succeeded and the removed key was dropped without a word; the orphan schema was deleted. | "an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word" (health-check, OTel exporter) | | `objectstack-ai#3821` | The sharing-rule page: an unsortable query fell through to an empty page, and the driver fix made an unsortable query lose its ORDER BY, not its rows. | "the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows)"; "the ladder's own premise — rows matter more than their order"; "the ladder's recoveries" | | `objectstack-ai#4484` | `IDataDriver.findStream` removed: no production caller, two of three implementations buffered the whole set, and no tombstone because nothing parses a driver object. | "Retiring `IDataDriver.findStream` (it had no production caller, and two of its three implementations read the whole result set into memory …)"; "(`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver object)"; by entry id in the `distinct` entry | | `objectstack-ai#4583` | The datasource ledger's dead keys removed; `capabilities.*` went as a whole block (11 of 11 unread). | "was retired separately, as a whole block nothing read" | | `objectstack-ai#4634` | Audit of all 34 `DriverCapabilities` bits: 3 live, 31 dead and tombstoned. | the entry already states the audit ("the follow-up audit checked every bit"); the trailing id is dropped | | `objectstack-ai#4914` | Maintainer, 2026-08-04: remove `manifest.loading` and `PluginHotReloadSchema`; keep `HotReloadConfigSchema`, the side with an implementation (`HotReloadManager`), as the start point. | "kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, …" | | `objectstack-ai#4984` | An org-axis red-line gate read only aliases the schema rejects while its own fixtures spelt them: tests green, rule dead. | "the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead" | | `objectstack-ai#5181` | Narrow the query parameter of `IDataDriver`'s methods (`DriverQuery`, no redundant `object`). | "neither the narrowing of `IDataDriver`'s query parameters to `DriverQuery` nor the follow-through …" | | `objectstack-ai#5499` | Maintainer, 2026-08-05: freeze investment in `driver-memory` / `driver-mongodb`; fully lifted 2026-08-11 (comments `5249019855`, `5252526378`). | "the maintainer's 2026-08-05 investment freeze on driver-memory, which was lifted on 2026-08-11" | | `objectstack-ai#5540` | Remove `IStorageService.list(prefix)`: zero consumers, and the two adapters answered differently and both incompletely. | "(the zero-consumer `IStorageService.list`, whose two adapters answered differently and both incompletely)" | | `objectstack-ai#6011` | Maintainer: close the `ctx.user` `roles` alias now. | "(the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window)" | | `objectstack-ai#6075` | **404** — see Acceptance notes. | "the follow-through that brought five drivers' implementations in line" | | `objectstack-ai#6320` | `distinct`'s third argument meant different things on memory and sql; the sql half was dispatched, the memory half held under the freeze. | "(the measurement that found the two drivers reading this argument differently split the fix: the sql half is this entry, and the memory half was held back by that freeze)" | | `objectstack-ai#6321` | `query.aggregate` / `agg.func` are undeclared aliases whose only writers are driver fixtures; order: re-spell the fixtures, delete the aliases, then narrow the signature. | "The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed to `DriverQuery` last — because the reverse order yields red nobody can explain." | | `objectstack-ai#6404` (PR) | Executed that order and narrowed `aggregate`'s query parameter to `DriverQuery`. | the same sentence | | `objectstack-ai#7929` | Maintainer, 2026-08-12, ruling B: `driver-sql`'s filter refusal stops echoing `$field` operands, for every caller; the full diagnostic goes to the server log. | "the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response)"; "that disclosure shape closed on the last dialect" | | `objectstack-ai#8371` | Ruled option 2: a dotted filter key whose head is a relation, a formula or a scalar is refused at both doors; a structured head stays unjudged. | "the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors" | | `objectstack-ai#8592` | Measured on live MySQL: knex compiles the named conflict target away. | stated by the entry ("knex drops the named keys before the statement leaves the process"); the trailing id is dropped | | `objectstack-ai#8621` | Option A: a pre-flight refusal when no unique index backs the caller-named conflict target. | "Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target …" | | `objectstack-ai#8622` | `id` becomes insert-only on the merge path: a merge on a non-primary conflict key was measured rewriting the existing row's primary key. | "`id` is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key)" | | `objectstack-ai#8755` | Ruling option A: a pre-flight refusal when a second unique key could absorb a backed, caller-named target. | "… and the half where a rival unique key could absorb a caller-named one" | | `objectstack-ai#8790` | Maintainer, 2026-08-15: refuse both halves with `INVALID_FILTER` / 400, naming the column. | "Ruled by the maintainer on 2026-08-15: refuse BOTH halves …"; "Recover-both was excluded by the ruling's own argument" | | `objectstack-ai#8807` | Maintainer, 2026-08-15: an upsert must never modify a row whose identity the caller did not supply and whose conflict key it did not name; enforcement delegated, blanket refusal excluded. | "Ruled by the maintainer on 2026-08-15, as a contract principle …" (the principle itself was already quoted verbatim) | | `objectstack-ai#8926` | Maintainer, 2026-08-16, option A: MySQL's spelling joins the one shared predicate (envelope and recoveries together). | "Addendum 2026-08-16." — the paragraph already states option A | | `objectstack-ai#9061` (PR) | Implemented that option A. | the same | | `objectstack-ai#11825` | Maintainer, 2026-08-25: retire the declarative `AdvancedPluginLifecycleConfig` container; the classes stay a host-driven library. | "… and as a host-driven library when the declarative lifecycle config container was retired" | | `objectstack-ai#11846` | **404** — see Acceptance notes. | "maintainer ruling 2026-08-27 (Option A: remove)"; "(as the removal ruling recorded)" | | `objectstack-ai#14478` | Ruling B, 2026-09-02: a no-baseline gate plus an ADR-0087 rename of every offender (`DriverOptions.timeout` among the seven named); ruling B again, 2026-09-05: the population is every authored and every runtime-emitted duration, minus exemptions declared on the schema. | "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself)"; "the duration-unit rule (…)" | | `objectstack-ai#14519` | The two tenant timeouts published a describe naming no unit (the unit sat in the JSDoc only); folded into the rename. | "the unit-nowhere shape (no unit in the name or in the published describe, first measured on two tenant timeouts)" | | `objectstack-ai#15626` (PR) | Landed the gate and the seven founding renames, the tenant `idleTimeout` → `idleTimeoutSeconds` among them. | "The tenant half was already renamed, in the same change that landed the duration gate itself" | | `objectstack-ai#15678` | `kernel/`: the 14 remaining duration keys carry their unit in the key name. | "the kernel-directory duration renames" / "the kernel-directory round"; trailing ids dropped | | `objectstack-ai#15679` | `system/`: the 15 remaining duration keys carry their unit in the key name; `size` got an honest name. | "the system-directory duration round"; trailing ids dropped | | `objectstack-ai#15939` | The gate did not read JSDoc. Ruled 2026-09-07: refuse the JSDoc / describe divergence. Ruled A 2026-09-11: remediate the population per file first, land the widened gate last. | "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding … a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands" | | `objectstack-ai#16024` | Maintainer, 2026-09-06, per key: forward `timeout`, remove `localPath` and `wasm`. | "ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured" | | `objectstack-ai#17635` (PR) | The widened gate: refuse a duration key whose JSDoc names a unit its describe does not; landed last. | "lands that widened gate last, into a tree already clean" | | `objectstack-ai#18669` | Ruling A, 2026-09-17: rename `FileValue.duration` and `estimatedMigrationTime`, each with an ADR-0087 entry; no new closed type, no narrowing of stored data. | "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express …" | | `cloud#1651` | **Not readable from this session** — see Acceptance notes. | "cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch …" | No call-shaped token moves: a `name(` census over `registry.ts` is identical before and after (297 distinct tokens), so textual call-spelling ratchets read the same. ## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`, widened `COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`, `kernel-`, `system-`. The pin still spawns the real CLI (`os migrate meta --from 16 --to 18`) once, locates each covered block **verbatim** in stdout, and asserts the printed block — `surface` included — carries no `#` plus 4 or 5 digits. Anti-vacuity: - the `REWRITTEN` floor rises from 29 to **55** ids: the 26 entries of this stage (7 `driver-`, 9 `kernel-`, 10 `system-`) are added, and every covered prefix must still select at least one entry; - presence in stdout is asserted before cleanliness (the `driver-sql-unresolvable-where-column-refused` reason carries two blank-line paragraph breaks, and its block is found verbatim); - the detector is exercised on both sides first (lit on 4 and 5 digits, dark on 3, 6 and `ADR-0112`). The file keeps its stage-1 name; the header lists the six covered families. ## Ablation — the widened pin can fail on a `driver-` block From committed state, HEAD `1c0dc7ad54`, with `scripts/ablation-replace.mjs` in wrap mode (it owns the restore trap) and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is built from the generated `registry.ts`, so that is the file mutated. - **Mutation.** In `registry.ts`, the reason of `driver-sql-upsert-cross-row-identity-merge-refused`: anchor `pre-flight refusals closed the half` → `pre-flight refusals (objectstack-ai#8621) closed the half`. The tool read anchor 1 → 0 and replacement 0 → 1, blob `b41e1d44` → `8f8d226e`. - **Mutate leg** (one lock turn: build, preflight, pin). Spec build exit 0. Preflight: marker present in 4 built files. Pin: **red**, `1 failed | 2 passed` — `driver-sql-upsert-cross-row-identity-merge-refused: the printed guidance cites a tracker id: expected 'objectstack-ai#8621' to be undefined`. - **Restore.** Tool-proven: blob `b41e1d44` == HEAD, `git diff HEAD` empty. - **Restore leg.** One lock turn, taken on the second try (the first waited out its 540 s budget, exit 99, NOT MEASURED, the tree already restored). Spec build exit 0. The `--absent` preflight found the marker in none of 222 built files, with the working tree clean against HEAD. Pin: **green**, `3 passed`. ## Verification Final head **`1c0dc7ad54`** for every line below; each heavy run went through `scripts/pm/os-verify-lock.sh` (one turn, `VERDICT command-exit 0`, per-step exits recorded separately). - **Build:** `pnpm exec turbo run build --concurrency=2 --filter='@objectstack/cli^...'` gives `Tasks: 55 successful, 55 total`. - **Pin and its neighbour:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta-engine-guidance.test.ts test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`, `Tests 10 passed | 1 skipped` (the skip is the default-range file's own pre-existing `skipIf`). - **Spec tests that read these entries or the registry:** `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/migrations src/kernel/preview-mode-retirement.test.ts scripts/build-schemas-check-mode.test.ts` plus the 19 other spec test files that read `MIGRATIONS_BY_MAJOR`, the registry or an entry file: `Test Files 24 passed`, `Tests 696 passed`. - **CLI unit:** `test/vitest-tiers-partition.test.ts` and `src/utils/spec-release-changes.test.ts`: `Test Files 2 passed`, `Tests 28 passed`. - **The call-spelling census that reads `registry.ts`:** `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-query-signature.test.ts` gives 15 passed. - **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exits 0 (test layer: 53 files / 255 errors held in its ledger); `pnpm --filter @objectstack/cli typecheck` exits 0 (test layer: 3 files / 28 errors held, unchanged). - **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives **89** families at `1c0dc7ad54` (after `git fetch origin main`). `--ran` over the recorded exit codes reads **89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0. They include `check:doc-authoring` ("16466 customer-facing string(s) across 1135 spec sources clean"), `check:issue-citations`, `check:migration-registry` ("registry.ts is current (300 semantic, 219 retired-key, 199 retired-def)"), `check:spec-changes`, `check:upgrade-guide`, `check:generated` ("All 15 generated artifacts are up to date"), `check:duration-unit-keys`, `check:nul-bytes`, `check:adr-0087-registration` and `check:changeset-no-major`. - `check:dual-build-cjs-loads` refused first with `PREREQUISITE NOT MET` (exit 3: twelve packages outside the CLI closure had no `dist/`). Those `dist/` directories were written later in the same pass (04:48–04:49Z, inside the `check:type-check-debt` run, whose re-measure builds them); re-run at the same head it exits 0 (104 entries / 66 packages / 659 CJS files). The reconciled list takes that latest run. - **Lint (a proven narrowing, not the repo-wide run, which is CI's):** `eslint --no-inline-config --format json` over the 28 changed `.ts` files reports 28 files, 0 errors, 0 warnings. - The population is read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 28 are in it (no file-ignored warning). - Invariance: the config enables no type-aware linting (no `parserOptions.project`, no typed rules), so a text edit cannot move the verdict on a file it does not touch. - **Mergeability:** see Acceptance notes (driver-free `merge-tree` against `862b6ce869` exits 0). ## Acceptance notes - **Two dead ids, rewritten from the code on `main`.** `objectstack-ai#6075` and `objectstack-ai#11846` answer 404 on both the issues and the pulls endpoint, re-probed with a 200 control (`objectstack-ai#14478`). - `objectstack-ai#6075` (`distinct`'s "never reached it" sentence): `packages/drivers/driver-sql/CHANGELOG.md` (commit `d367f03`) and `sql-driver-query-signature.test.ts` record what it did — the five drivers' implementations followed `IDataDriver`'s `DriverQuery` narrowing. The sentence now says exactly that. - `objectstack-ai#11846` (preview mode): `packages/spec/CHANGELOG.md` (commit `0c2334f`), `packages/spec/src/kernel/context.zod.ts` and `preview-mode-retirement.test.ts` record the 2026-08-27 ruling (Option A: remove), the three-repo zero-consumer measurement and the re-declare-fresh condition. Dropped because `main` does not state them: "decision-inbox batch 2" and "all four decision facets pointed the same way". "The objectstack-ai#11846 card records the measurement" (objectui leg) now reads "zero consumers, measured when the removal was ruled", which is what the changelog and the test header say. - **One cross-repo id this session cannot read.** `cloud#1651` answers 403 here: `objectstack-ai/cloud` is not attached to this session (`add_repo` refused: no access). It is neither confirmed nor refuted, so its sentence was rewritten from what `main` records about it — `packages/spec/CHANGELOG.md` (`0c2334f`: closed 2026-08-26 with positive controls, `RuntimeMode` zero hits, `ArtifactKernelFactory` 20+ hits and never touching `previewMode`) and `context.zod.ts` (`OS_PREVIEW_MODE` there is routing-only). The cloud-side detail `main` does not state — `previewMode` "only as a local variable" whose effect is adding wildcards to "CSRF" trusted origins — is dropped; the parenthesis that replaces it describes this repository's own `serve.ts` (the one reader of `OS_PREVIEW_MODE` here only widens better-auth's trusted origins to preview-domain wildcards), which is measured on `main`. - **Decision-batch numbers went too (invisible to the regex).** Nineteen sites cited a decision batch as `#` plus two or three digits (`objectstack-ai#43` ×13, `objectstack-ai#115` ×4, `objectstack-ai#151` ×1, `objectstack-ai#158` ×1). They are numbers an author is shown and cannot follow, so each is dropped. One consequence worth naming: 13 entries said `Maintainer ruling B on objectstack-ai#14478 (2026-09-02, decision batch objectstack-ai#43)`, which fused two rulings on the same card — B of 2026-09-02 (the gate and the no-baseline rename) and B of 2026-09-05, decided in that batch (the population: every authored and every runtime-emitted duration, minus schema-declared exemptions). The sentence now names both dates. The `objectstack-ai#158` sentence (the agreement shape ruled an offence on 2026-09-18) is corroborated by `.changeset/18075-agreement-shape-is-an-offence.md` on `main`. - **"issue NNNN" / "PR NNNN" spellings, checked by hand.** No bare-number spelling exists in these 26 entries' author-shown text; the two `PR` citations (`PR objectstack-ai#6404`, `PR objectstack-ai#9061`) were `#`-spelled, so the instrument saw them and they are gone. The only `#` left in these 26 files is on `//` comment lines (sibling card's surface), including a `Prime Directive objectstack-ai#13` reference. - **A citation whose page says something narrower than the text.** `kernel-health-check-and-hot-reload-durations-unit-in-key` called `shutdownTimeout`'s shape "the objectstack-ai#14519 unit-nowhere shape". `objectstack-ai#14519`'s keys carried their unit in the JSDoc; "unit nowhere" is the gate's name for it (`check-duration-unit-keys.ts` header: "no unit ANYWHERE (the objectstack-ai#14519 shape)"), because the gate did not read JSDoc. The sentence now says what the shape is — no unit in the name or in the published describe — and that it was first measured on two tenant timeouts. - **A comment that my text edit makes slightly stale.** `18.system-metrics-window-durations-unit-in-key.ts` carries a `//` comment saying its acceptanceCriteria sentence "is objectstack-ai#15679's, left word for word". That sentence now says "that JSDoc-channel gap is filed as a finding of its own" where it said "is objectstack-ai#15939": same content, no number. The comment is the sibling card's surface (comment lines), so it is untouched here. - **Three "card" references re-anchored.** Removing an id left "the same card" in the Turso entry pointing at nothing; it now says "the same measurement". The kernel entries' "renamed by this same card" carry no number and were not otherwise rewritten, so they are left. - **Cross-PR check: no open PR adds or edits a `driver-`, `kernel-` or `system-` semantic entry.** Read at 2026-09-28T04:0xZ: the 18 open PRs' file lists (`GET /pulls/{n}/files`) carry 0 files matching `migrations/entries/semantic/NN.(driver|kernel|system)-*`. The Version Packages PR (`objectstack-ai#17076`) lists more than 1,000 files; the 1,100 rows read carry no entry file, and it is the bot-generated release PR. Nothing in flight will be held by the widened pin on arrival. - **`main` moved 4 commits past the base** (`862b6ce869`: `objectstack-ai#20364`, `objectstack-ai#20341`, `objectstack-ai#20366`, `objectstack-ai#20352`); none touches `packages/spec/src/migrations/` or the pin. A driver-free bare-clone `merge-tree --write-tree` of this head against `862b6ce869` exits 0 with no conflicted path, so `registry.ts` needs no merge, and `main` was not merged in. - **Generated projections** (`spec-changes.json`, `docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1 and 2; their `--check` legs are green. Only the three `driver-` entries registered at protocol 17 appear in them, which is why those diffs are small. - **No other test pins these entries' text.** A `git grep` of test files for the 26 entry ids finds one (`preview-mode-retirement.test.ts`), which names the entry in a comment and reads no prose; a grep of tests for the 37 cited numbers finds only comment lines. So no test needed re-pinning this stage (stage 2's `migrations.test.ts` case has no counterpart here). ## Line budget Entry files: **352 changed lines** (+228 / −124) across 26 files, against the stage-1 ≈400 budget. The whole diff is **776 lines** (+516 / −260) in 31 files. Of the rest, `registry.ts` is 352, the two projections are 18 (`spec-changes.json` 12, the upgrade guide 6), the widened pin is 33 and the changeset is 21. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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