feat(spec)!: tenant schemaCacheTTL carries its unit in the key name (#17784) - #17954
Conversation
`SchemaLevelIsolationStrategy.performance.schemaCacheTTL` named seconds in a source JSDoc only; the published `.describe()` said "Schema cache TTL" and named no unit, so a reference-page reader could not tell 3600 seconds from 3600 milliseconds. Renamed to `schemaCacheTtlSeconds` with the unit in the describe too, tombstoned with `retiredKey()` (the nested object is not strict), and registered as an ADR-0087 D3 semantic entry plus a RETIRED_KEYS_BY_MAJOR[18] row. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude <noreply@anthropic.com>
`gen:docs` moves the four `schemaCacheTTL` rows in content/docs/references/system/tenant.mdx onto `schemaCacheTtlSeconds` and renders the tombstone prescription in place of the old describe. check:authorable-surface and check:api-surface are unchanged — the ratchet records top-level keys per def and this one is nested under `performance`. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 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 2105eeb378bef4ec650d81fbc904b2305bcaa5bf && git checkout 2105eeb378bef4ec650d81fbc904b2305bcaa5bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f62787c033e5f36bc02a2eafff62ff84d73c9b70 ee19e715606d215e294d04c72bbc23bbb946ef2c && git checkout -B drift-repro f62787c033e5f36bc02a2eafff62ff84d73c9b70 && git merge --no-ff ee19e715606d215e294d04c72bbc23bbb946ef2c
node scripts/docs-audit/affected-docs.mjs --json f62787c033e5f36bc02a2eafff62ff84d73c9b70 |
PM status on the two red checks — both diagnosed, both this PR's, fixes in flightEpic PM for the #15939 subtree, 1.
|
| line | entry |
|---|---|
:1804 |
feat(spec)!: the fourteen kernel/ duration keys carry their unit in the key name (#15678, ruling B on #14478) |
:2764 |
feat(spec)!: the fifteen system/ duration keys carry their unit in the key name (#15679, …) |
:124 · :675 · :912 |
same shape, feat(spec)!, each with an adr-0087: registered disposition |
Ruling A prescribes "patch … following the #15678 / #15679 shape" — and that shape is feat(spec)! at minor, four times over, never once a patch. The two halves of the ruling's own clause disagree; the measurable half wins. Corroborated independently by the maintainer ruling the gate quotes at itself (2026-09-04, decision batch #35, on #15294: "a purely additive widening … takes AT LEAST minor").
⇒ Fix: the changeset rises to minor / feat(spec)!, keeping the [BREAKING] marker and the ADR-0087 disposition that check-adr-0087-registration already passed. Recorded for the whole epic on #15939 — it applies to all six rename cards, ⛔ not to #17786 / PR #17953, which adds no key and stays at patch.
2. Type Check · source gates — entirely this PR's, unrelated to the above
@objectstack/spec check:objectui-pin-citations → ✗ 2 pin-citation / anchor problem(s) in packages/spec/src. The gate accepts exactly two spellings and says "an unrecognised spelling is not a pass: it leaves the citation outside every check, which is the silent state this gate exists to remove." The cross-repo pin citation in this PR's prose uses a third. ⇒ Fix: use the spelling that is true of what was done (this round checked against the pin). ⛔ Not by weakening the gate, ⛔ not by dropping the citation.
3. PR body declaration line
check-changeset-no-major reads the PR-body line, not my correction comment, and it printed the disagreement: "· carrier: needs:contract-review IS on this PR · declaration line: (negative)". The body line moves to the affirmative to match the card. ⇒ Note that check-clause2-carriers --pair 17954 already reads exit 0 via the correction comment; this is the human-legibility and changeset-gate half of the same fact.
Also read this wake, and judged as needing no action
Docs Drift Check — advisory, names no page. It declares its own blind spot honestly: the retired-key entry 18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts yields no anchor, so pages documenting it are not covered by that run. That is a limitation of the drift tool, ⛔ not a defect in this diff, and the generated reference page for this key is regenerated in this PR by gen:docs. No action.
Posture
All three fixes are with the round now. ⛔ Draft stays draft; no ready, no enqueue, no auto-merge. An in-seat clause-② contract review at CONTRACT_REVIEW_TIER is owed on this PR before any of that, and it is this seat's to supply — per the maintainer's direct instruction today, quoted verbatim: 「你自己直接负责契约复审」.
epic PM for #15939 · session_015c5G6TmpMKgnusmTpD7Ntt · 2026-09-13T08:20Z
Generated by Claude Code
…ecked spelling Three corrections after the PM flipped card #17784's clause-② declaration to `yes` under the contract-review floor (a rename adds a new key on a published payload): - the changeset grades `@objectstack/spec` `minor`, matching the four sibling duration-rename changesets of this same rule, and its summary takes the `feat(spec)!` shape they use - the semantic entry cites the pinned objectui checkout as `.objectui-sha` = `<sha>`, one of the two spellings `check:objectui-pin-citations` recognises — it was in a third spelling, which the gate reports as a citation outside every check - registry.ts regenerated so its mirror of that prose matches Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude <noreply@anthropic.com>
schemaCacheTTL carries its unit in the key name (#17784)schemaCacheTTL carries its unit in the key name (#17784)
Contract reviewHead sha reviewed: Every tree reading below was taken in a worktree checked out at that sha, with ① derived judgments — accept set and public surface
② semver grading —
|
…nant-schema-cache-ttl-unit
Carrier cleared — provenance.
|
…tion keys carry their unit in the key name (objectstack-ai#17986) Fixes objectstack-ai#17780 Clause-②: yes Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115) for `packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts` — 3 rows of the 21-row delta, following the objectstack-ai#15678 / objectstack-ai#15679 shape. The declaration above reads the affirmative: the rename puts three spellings on a published payload no author could write before. `check-clause2-carriers --pair 17986` reads **exit 0** — declaration readable in the fixed spelling, both carriers agree. ## The three rows, re-located by symbol Each key was confirmed to be the **only** key-position declaration of that name in the file (`interval` 1, `timeout` 1, `debounceDelay` 1; lit control `z.number` 12, dark control 0), and each JSDoc was read rather than inferred from the default: | key | JSDoc, as read | published `.describe()`, before | new spelling | |:--|:--|:--|:--| | `PluginHealthCheck.interval` | "Health check interval in **milliseconds**" | "How often to perform health checks (default: 30s)" | `intervalMs` | | `PluginHealthCheck.timeout` | "Timeout for health check in **milliseconds**" | "Maximum time to wait for health check response" | `timeoutMs` | | `HotReloadConfig.debounceDelay` | "Debounce delay before reloading (**milliseconds**)" | "Wait time after change detection before reload" | `debounceDelayMs` | All three are milliseconds. The `check:duration-unit-keys` census reads all three `[name: -] [prose: -]` on `origin/main` — no unit in the name and none the gate recognises in the published prose. `interval` is the sharpest: its one unit-shaped token was the parenthetical `(default: 30s)`, naming SECONDS for a value the schema bounds at `min(1000)` and defaults to `30000` milliseconds. ## Why these spellings Counted on this tree, not assumed from the card: 100 key-position `*Ms` declarations across `packages/spec/src`, of which `timeoutMs` is 29 and `intervalMs` is 3 — both renames land on names the surface already uses. The analogous question to the sibling round's `Ttl`-versus-`TTL` was asked for `debounceDelay` and answered the other way: it is the only debounce-shaped key spelling in the repo (5 key-position occurrences, all this key and its fixtures; no `debounceMs` variant anywhere), while the Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`). There was no competing family spelling to choose between, so it takes the plain suffix. ## The kit - `retiredKey()` tombstone on each old spelling. Neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, so a bare deletion would be a silent strip — and here the stripped value lands on a `setInterval` period, a race deadline and a `setTimeout` delay. - ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` **and** three `RETIRED_KEYS_BY_MAJOR[18]` rows — both, per the PM's settlement on objectstack-ai#15939. No D2 conversion: neither def is an authorable surface (both are library parameters a host passes to `PluginHealthMonitor` / `HotReloadManager` in TypeScript), the same disposition `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` recorded for keys on these two defs. `registry.ts` is **generated** — `gen:migration-registry`, never hand-merged. - `@objectstack/core` moves with the rename (it is the only in-repo reader): both classes read the suffixed keys, and each registration-time refusal table gains a row so a host still passing an old spelling gets an ADR-0112 `VALIDATION_ERROR` / 400 naming the rename instead of `undefined` where a duration belongs. Those two runtime strings carry **no tracker id** — a runtime string reaches operators who cannot resolve one; the anchor sits in an adjacent `//` comment (`check:doc-authoring`). - Generated artifacts regenerated, never hand-edited: `authorable-surface/kernel.json`, `authorable-defaults/kernel.json`, `content/docs/references/kernel/plugin-lifecycle-advanced.mdx`. `pnpm --filter @objectstack/spec check:generated` reports 15 of 15 up to date. - Hand-written `content/docs/protocol/kernel/lifecycle.mdx` updated where it names the keys (+8 / −6). ## The authorable-surface ratchet MOVES here, and that is correct `authorable-surface/kernel.json` records **top-level keys per def**. All three of these keys are top level, so the rename must move the ledger — three `[RETIRED]` markers added beside three new suffixed rows, plus the matching `authorable-defaults` moves. This is the opposite reading from sibling PR objectstack-ai#17954, whose key was **nested** (`performance.schemaCacheTTL`) and correctly moved nothing: different readings, same rule, both right. Regenerated by `gen:schema`, never hand-edited. ## Pin tests replaced, not deleted Two minimum-bound pins are the ones worth calling out. Spelled through the bare keys, `PluginHealthCheckSchema.parse({ interval: 500 })` would have kept throwing — off the **tombstone's refusal**, not the `min(1000)` bound, a pin that can no longer fail. They now assert the `too_small` issue code on the suffixed keys plus an accepting control at the boundary. The default and fixture pins were re-pointed at the new spellings with `not.toHaveProperty` on the old ones; nothing was deleted, weakened or skipped. New pins: the refusal carries the rename prescription (asserting the issue is not `unrecognized_keys`), the suffixed keys parse at the magnitude the retired ones carried with the same defaults, the describes publish the unit, and both core classes refuse an old spelling at `registerPlugin` before the plugin is stored, with an accepting control beside each. ## Consumer sweep — `@objectstack/core` is the only reader | subject, outside `packages/spec` and `packages/core` | occurrences | |:--|--:| | `PluginHealthCheckParsed` / `HotReloadConfigParsed` | 0 / 0 | | `PluginHealthCheck` · `HotReloadConfig` · `PluginHealthMonitor` · `HotReloadManager` | 4 · 6 · 2 · 7 — **all prose**: changesets, the generated upgrade guide, a `//` comment in a `packages/rest` pin test, and two CHANGELOG lines. Zero code readers. | | the three keys in a health-check / hot-reload context | 0 | Lit controls on the same corpus: `@objectstack/spec` 14580, `PluginContext` 973, `ObjectKernel` 588. Dark control `zzqqxx` 0. ## Pinned objectui: 0, with controls `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — this IS the pin we build against (checked here). Grepped at that sha across its 6409 tracked files: all **thirteen** exports of `plugin-lifecycle-advanced.zod.ts` occur 0 times, and `debounceDelay` occurs 0 times. Lit controls on the same corpus: `objectstack` 10171, `@objectstack/spec` 3479, `timeout` 832, `interval` 156. Dark control `zzqqxx` 0. Console Pin Gate is unaffected. ## Verification | command | verdict | |:--|:--| | `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — 15 of 15 artifacts current | | `pnpm --filter '@objectstack/core^...' build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/core build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/core test` | `VERDICT command-exit 0` — 51 files, 1316 tests | | `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` — 475 files, 13517 tests | | `pnpm --filter @objectstack/spec --filter @objectstack/core typecheck` | `VERDICT command-exit 0` | | `dispatch-gates --commands` → all 110, reconciled with `--ran … :: exit N` | **104 exit 0 · 0 red · 6 exit 3** | The six exit-3s are `PREREQUISITE NOT MET` on unbuilt sibling workspace packages outside this change's build closure (`@objectstack/lint`, `@objectstack/formula`) — NOT MEASURED by the gates' own classification, and built by CI. `check:skill-examples` initially refused with **exit 1** on an unbuilt `@objectstack/client-react` — the refusal shape that a `--ran` reconciler counts as run; it was measured properly after building that closure and reads exit 0 (258 prose examples across 3 surfaces). ## Acceptance notes - **`HotReloadConfig.shutdownTimeout` is deliberately not renamed with these three.** Its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere — it is the unit-nowhere shape the objectstack-ai#14478 gate leaves outside its verdict by measurement, and it is not one of the 21 rows PR objectstack-ai#17635 enumerates. Noted, not filed: the gate's own `--list` census already keeps it visible, and its header records that closing that class is a describe-by-describe decision rather than a defect. - `check-widening-tells` raising T1 on the `retiredKey()` tombstone lines is the known inverted false positive (objectstack-ai#17955) — the lines it fires on make the accept set strictly narrower. The diff is not reshaped and the checker is not weakened. - **Docs Drift Check named six pages; five are false positives.** Its anchor is a bare `timeoutMs` string literal inside the new retirement constant, so it listed every page using that generic word: `automation/jobs.mdx`, `automation/webhooks.mdx`, `automation/flows.mdx`, `deployment/environment-variables.mdx` and `automation/hook-bodies.mdx` — job scheduling, webhook retry backoff and the ADR-0102 sandbox CPU budget, none of them `PluginHealthCheck` or `HotReloadConfig` (0 plugin-health mentions each; `hook-bodies.mdx`'s two "hot-reload" hits are Studio in-browser editing). The sixth, `protocol/kernel/lifecycle.mdx`, is the genuine hit and is edited here. The three `content/docs/releases/` pages are release-owned and untouched. Recorded because the four remaining rename cards in this epic will trip the same anchor. ⛔ Draft on purpose: the in-seat clause-② review at tier is owed first, and landing is the PM's step. --- _Generated by [Claude Code](https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt)_ Co-authored-by: Claude <noreply@anthropic.com>
…ir unit in the key name (objectstack-ai#17785) (objectstack-ai#18016) Fixes objectstack-ai#17785 `Clause-②: yes` — each rename puts a spelling on `OpenTelemetryCompatibility` and `TracingConfig` that no author could write before. `minor`, `feat(spec)!`, with an `adr-0087: registered` disposition (epic settlement on objectstack-ai#15939, correcting Ruling A's own `patch`). Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115), the per-file remediation of the objectstack-ai#14478 rule — the **seventh and last** of that ruling's seven cards. ## The four rows Re-located by symbol path on `origin/main` @ `98bd7986fe`; line numbers came from PR objectstack-ai#17635's enumeration and are re-derived here, not trusted. | symbol path | before | after | default | |:--|:--|:--|--:| | `OpenTelemetryCompatibility.exporter` | `timeout` | `timeoutMs` | 10000 | | `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout` | `exportTimeoutMs` | 30000 | | `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay` | `scheduledDelayMs` | 5000 | | `TracingConfig.performance` | `exportInterval` | `exportIntervalMs` | 5000 | Each declared exactly once in key position, lit control `z.number` 20 on the file, dark control 0. **All four carried no `.describe()` at all** — not "a describe that names no unit", but none — so the published reference row was a bare integer with no unit anywhere on the page. Values, defaults and `int().positive()` bounds are unchanged. ## The suffix is `Ms`, settled from tree counts Key position across `packages/spec/src`: **281** `*Ms` declarations over 42 distinct names — `timeoutMs` 65, `intervalMs` 14, `durationMs` 73 — against **0** key-position `timeoutSeconds` and 77 `*Seconds` of any name. `exportTimeoutMs` / `scheduledDelayMs` / `exportIntervalMs` have 0 competing spellings of any form. The Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`). This file's own landed precedent is `Span.duration` to `durationMs` (objectstack-ai#15679) — **the opposite of the sibling metrics card, whose rows were seconds**; every JSDoc here was read individually and every one says milliseconds. **The two `*TimeoutMs` keys one nesting level apart — accepted, and why.** The near-duplicate pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) sitting beside the exporter's own request deadline. Renaming either to something more distinctive would depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`, never interchangeable because they live in different objects. Rejected alternative: `requestTimeoutMs` for the outer one, which would have invented a spelling OTel does not use to solve a problem the nesting already solves. ## The ratchet moves nothing — and that is the correct outcome, not an omission Verified from the generator source, not assumed. `packages/spec/scripts/build-schemas.ts:852` reads `schema.properties` **one level deep** and records `${defKey}:${name}`, so only top-level keys per def ever enter `authorable-surface/` or `authorable-defaults/`. Corroborated on the committed artifacts: `system/OpenTelemetryCompatibility:exporter` is a single row (the whole nested object), there are **0** dotted keys anywhere in `authorable-surface/system.json` and **0** in `authorable-defaults/system.json` against 247 total keys there, and the lit control `system/Span:duration [RETIRED]` is present. All four of my rows are nested. `check:authorable-surface` is **green without any regeneration**, and `git diff --name-only` against the base matches 0 files under `authorable-surface` or `authorable-defaults`. This is the objectstack-ai#17954 reading, not objectstack-ai#17986's. ## The pin guard this card was written to trip `packages/spec/src/system/tracing.test.ts:545` — `it('leaves the OTel exporter timeout alone — its describe names no unit, so it is outside the population')`. **It succeeds by failing**: its key, its "names no unit" clause and its "outside the population" clause all go false here. It is neither deleted, skipped, weakened nor fixed green — it is **replaced and relocated** out of a `describe` headed `Span.duration carries its unit (objectstack-ai#15679)`, which is no longer its subject, into a new `objectstack-ai#17785` block carrying the three-part shape objectstack-ai#15679 itself set on this file: a refusal pin per row asserting the issue **code** (`not.toBe('unrecognized_keys')`) and the FROM to TO prescription, an acceptance pin at each retired key's magnitude with the same default, a bounds pin, and a describe pin proving the unit now reaches the published channel. ## Stale-prose sweep (`file:line`) Swept `packages/spec/src`, `packages/spec/scripts` and `content/docs` for sentences this card falsifies (`outside the population` · `names no unit` · `no unit at all` · `left alone` · `leaves ... alone` · `untouched here` · `stays bare`), then narrowed to hits whose SUBJECT is one of my four keys. Lit control `unit` 2308 on that corpus, dark control 0. | `file:line` | disposition | |:--|:--| | `packages/spec/src/system/tracing.test.ts:545` | this card's — replaced and relocated, above | | `packages/spec/CHANGELOG.md:2866` | **left alone, reported.** "`ServiceLevelObjective.errorBudget`'s burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit anywhere in their prose, so both are outside the gate's population entirely." Two limbs go false — mine here, the `window` one at objectstack-ai#17783. `packages/*/CHANGELOG.md` is RELEASE-OWNED (AGENTS.md): never edited in a code PR; a factual error in a released entry is amended in a dedicated docs-only PR. It is also jointly owned by a sibling card, so it is not this PR's sentence to rewrite. Raised in the report. | | `packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts` | **left alone, deliberately.** Its "it is the only offender on its file" is scoped to ruling B's describe-channel population, which never contained these four. Per the epic settlement, a predecessor's semantic entry is never amended; this round's entry instead opens by saying how it relates to it. | ## Consumers **Zero in-repo readers outside `packages/spec`.** `OpenTelemetryCompatibility`, `TracingConfig`, `exportTimeout`, `scheduledDelay` and `exportInterval` each occur **0** times across the whole tree at `98bd7986fe` excluding `packages/spec` **and** `content/docs/references`, against a lit control of **18920** `Schema` occurrences on exactly that corpus — both counts from one `git grep -o` over that ref carrying both pathspec exclusions — and a dark control of 0. The named consumer packages were each run anyway rather than reasoned about — including the package being edited, the objectstack-ai#17986 lesson. **Pinned objectui**, `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, 6409 tracked files: all **37** exports of `tracing.zod.ts` and each of the four key names occur **0** times. The 404 `Span` and 40 `SpanSchema` raw hits are objectui's own HTML text-span component (`TextSpanSchema`) — a different name, read rather than counted. Lit controls `objectstack` 10171 and `@objectstack/spec` 3479 on the same corpus; dark control 0. **Rollback** is therefore consumer-free: reverting this PR restores the four bare spellings and drops four tombstones no caller in this repo, and none in the pinned sibling, reads. ## The kit - `retiredKey()` tombstone per row — none of these shapes is `.strict()`, so `unrecognized_keys` was never the alternative: a bare deletion is an ADR-0049 silent strip landing a default on an exporter deadline and a background export period - ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` **plus** four `RETIRED_KEYS_BY_MAJOR[18]` rows (the epic settlement: both, every time) - `registry.ts` regenerated with `gen:migration-registry`, never hand-merged - `content/docs/references/system/tracing.mdx` regenerated with `gen:docs` - `minor` changeset with the **BREAKING** banner, the FROM to TO mapping for all four and the `adr-0087: registered` disposition ## Acceptance notes - The prescriptions carry **no** `os migrate meta` sentence, matching objectstack-ai#17784 and objectstack-ai#17780: the sentence belongs to a surface an ADR-0087 **conversion** covers, and these are D3 semantic entries with no D2 conversion (`stack.zod.ts` declares no tracing collection; a tracing config is never a stored `sys_metadata` row). The class pin `retired-key-migrate-sentence.test.ts` deliberately does not judge a prescription that names no command. - Tombstone prescriptions are written **inline** rather than extracted to a `const`, which is the objectstack-ai#17954 shape on a nested key and sidesteps the `OS_EAGER_SCHEMAS=1` temporal-dead-zone trap objectstack-ai#17983 hit by construction — there is no const to order. - `acceptRetiredDefaultResidue()` was considered and **not** used: all four keys are defaulted, but every landed sibling of this epic tombstoned a defaulted key with plain `retiredKey()`. Noted, not filed — raised as an open question rather than decided here. - `content/docs/references/system/tracing.mdx` gains a `TracingConfig.performance` nested-shape section it did not have; the `exporter.batch` describes do not render a row because the generator's nested-shape rendering stops one level shallower. Generator behaviour, unchanged by this PR. Noted, not filed. Authored by the `os-dev` seat for the objectstack-ai#15939 epic PM, session `session_015c5G6TmpMKgnusmTpD7Ntt` (https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…new spelling (objectstack-ai#18427) Fixes objectstack-ai#17955 `check-widening-tells` raised **T1** — "a new key on a Zod object schema … the accept set gains a spelling an author may now write" — on the line that DECLARES A TOMBSTONE. `retiredKey()` returns `z.never(…).optional()`. The line it is written on makes the accept set strictly **narrower**: the key's `z.input` becomes `never` so `tsc` refuses it at the authoring site, and a value reaching the parse is refused carrying the migration prescription. There is no spelling an author "may now write" — there is one they may no longer write. ## Round 2 — clearing the at-tier FAIL Record `5696399878` judged the decline, its ordering against the budget, the history walk and the self-test reconciliation RIGHT, and named three required changes. All three are in. One of them ships **one step narrower than the record's wording, on a measurement that contradicts it** — declared in (a) rather than quietly chosen. ### (a) the value must BE the call, not merely open it `legacy: retiredKey('gone').or(z.string()),` and the same line with `.catch(undefined),` chain a LIVE arm onto the result, so the key stays writable. Both fired on the pre-objectstack-ai#17955 reading, both went silent under the first version of the predicate, and both fire again now. The rule is read per the branch the line takes: | the value | what may follow | measured population | |---|---|---| | **closes** the call on the key line | only a comma, a comment or end-of-line, after the balancing paren — found string-aware, so a paren inside the prescription cannot close the call early | **76** of the 254 | | **does not close** there | the line is a tombstone: every byte left on it is inside the argument list, and an argument chains onto nothing | **178** of the 254 | ⛔ **The record's second clause is NOT implemented literally, and this is the one deviation in the round.** "When it does not close on that line, only whitespace or a comment may follow `retiredKey(`" re-breaks **30 landed tombstones** — the prescription helper's own arguments continue on the next line, so the key line ends inside the argument list, not at the open paren: ``` create: retiredKey(capRemoved('create', 'CRUD is not optional for a driver: `create`/`find`/`findOne`/`update`/`delete` are ' + …, )), ``` Measured, with both legs: implemented literally, the tree simulation raises **30** T1 rows, all in `packages/spec/src/data/driver.zod.ts`, and the history walk shows those same 30 rows landing as additions in `d9fa683aaf` — i.e. it re-creates the exact false positive this card removes, on a real landed diff. As shipped: **0**. The literal clause also closes nothing: a key line that has not closed the call shows no chain to catch. The shape it was aimed at is the multi-line one, which the gate owner ruled open — see (c). ### (b) two firing controls, red on the predicate as the record found it Both are in the objectstack-ai#17955 battery's firing half, and both read `T1`: - `legacy: retiredKey('gone').or(z.string()),` - `legacy: retiredKey('gone').catch(undefined),` A third rides with them — `legacy: retiredKey('gone'), extra: z.string(),`, a second key spelled after the tombstone on one line, which the closed-branch rule also refuses to cover. ### (c) the header and the PR sentences the record falsified - **The chained-method shape is named as the residual quiet direction**, in the header and pinned in the battery: a MULTI-LINE tombstone whose CLOSING line chains the arm (`retiredKey(` on the key line, `).or(z.string()),` two lines down). The key line is a tombstone by every byte it shows and the closing line declares no key. Population **0**; control — the same scanner locates all **254** tombstone key lines across 66 files, 178 of them multi-line, and the single-line twin reads T1. The gate owner's ruling (`5696535481`) is recorded with its **overturn condition**: the first real multi-line chained carrier, landed and never a synthetic sample, closes it by reading forward. The header paragraph and that pinned case are the discovery device. - **The spelling census is corrected.** The earlier sentence — "every one of the 254 judged tombstones spells its prescription as the multi-line concatenation objectstack-ai#16822's continuation rule already declines" — is false. Measured by the branch the predicate itself takes: **178 multi-line** (148 ending at `retiredKey(`, 30 continuing into a prescription helper's arguments) and **76 single-line** (61 naming a constant, 15 calling a helper, 0 carrying a string literal). -⚠️ That is **not** the census the record states (148 multi-line + 106 single-line, 45 helper-call). The difference is exactly the 30 above: classifying by line SUFFIX — "ends at `retiredKey(`" vs "ends with `),`" — puts them on the single-line side, and 148 + 106 does not reconcile with what the tree holds (148 lines end at the open paren, 76 end with `),`, and 30 end with neither). The operative conclusion is unchanged and is reached by **direct simulation** rather than by the sentence: all 254 blocks fed back through this reader raise **0** rows of any kind where the pre-objectstack-ai#17955 reading raises **254** T1, so the residual T2 population is **0**. - The single-line STRING prescriptions: **4** key-shaped call sites in this tree, all in `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, which `surfaceFlags` puts off the contract source surface (`onContractSource: false`; control — `tenant.zod.ts` reads `true`). ### Judged in both directions — this is a gate other PRs must pass | question | reading | control | |---|---|---| | do the 254 in-tree tombstones all still decline? | **0** rows raised over all 254 blocks | the live-key twin of each of the same 254 lines fires: **254 / 254** | | does anything that should fire now stay silent? | **0** of 322 tombstone-shaped rows change verdict across the 224 commits in available history whose diffs move one (291 added, 31 removed — the removed side is where a lost decline could silently pay a budget) | the same walker, same subject, reading the record's literal clause instead: **30** rows change | | can the new cases fail? | predicate reverted to "opens the call" ⇒ **5 of 298** fail, exit 1 | predicate set to the literal clause ⇒ **1 of 298** fails, exit 1 — the pin that guards the 30 | History is the **available** history of a shallow checkout (`git rev-parse --is-shallow-repository` = `true`, 8,354 commits reachable), reported as available, not complete. ## Reproduced first, on the card's two probes and on the real bytes | probe | before | after | |---|---|---| | **A** — the rename alone (key line removed, suffixed key added, one change block) | exit **0** | exit **0** (unchanged) | | **B** — an added `retiredKey(` key line with no paired removal | exit **4** (T1 + T2) | exit **4** (**T1 gone**; the T2 is a synthetic one-line string prescription — see *Boundary*) | | **the real diff** — `git show fc28c1d`, the landed PR objectstack-ai#17954 | exit **4**, one row: `T1 packages/spec/src/system/tenant.zod.ts:454 + schemaCacheTTL: retiredKey(` | exit **0** | Probe A being green before and after is the load-bearing half: the objectstack-ai#16943 REPLACEMENT budget is EARNED by the removed key line and SPENT by the renamed one, so the tombstone is a **third** key line in the same change block and is the surplus. ## The reading The evidence is positive, hunk-local and absent by default, like every decline in this file: the added line's own **value** is the `retiredKey(` call and nothing after it. - ⭐ `retiredKey(` **stays** in `SCHEMA_PROPERTY`'s measured vocabulary. `memberTellKind` still answers `T1` for a tombstone line, so both sides of the budget keep reading one question. It is the **tell** that declines, never the vocabulary that shrinks. - ⭐ It is read **before** the objectstack-ai#16943 budget, and that ordering is the repair rather than a detail. An ADR-0087 rename puts three key lines in one change block, so the removal's budget is owed to the **rename**. Let the tombstone spend it and the rename reports as the surplus instead — and which of the two fires depends on nothing but the order the author wrote them in. ⛔ This is the *opposite* ordering from objectstack-ai#17300's licence, and the difference is the evidence: a licence is minted elsewhere in the diff, so reading it first would let it pay for a genuine member; a tombstone carries its evidence on its own line and takes nothing out of the block. - The **removed** side declines symmetrically, the way objectstack-ai#17618's parameter does. Un-retiring a key — dropping `legacy: retiredKey(…)` and putting a live schema back on that spelling — is a real widening, and a removed tombstone that bought the replacement would trade a loud failure for a silent one on the only diff shape that RE-OPENS a closed accept set. That leg **gains** diagnostics. ⛔ Not a weakening of T1, not a threshold, not an exclusion of `packages/spec/src/**` (objectstack-ai#17300 ruled that shape out by name), and not a lookup in the local tree (objectstack-ai#17300 measured that wrong for this whole population, because a retirement registers in the same PR). ## Measured before/after tell counts over this tree's history Walked with both readings over the same parsed diffs, on the surfaces taken from the module's own declarations (never hand-copied): - **1,674 commits** touching the tell surfaces in this tree's available history (shallow checkout) - of the **24,725** tell rows the previous reading raises, **125 now decline** and **24,600 stand** - all 125 are **T1**, and all 125 are `retiredKey()` tombstones by the very predicate that declines them — checked row by row, **0 exceptions** — spread over **23 commits** and **45 files** - **no T2, T3 or T4 row moves**, and **0 rows anywhere in that history begin firing**: the un-retiring leg has zero historical population, so it is a sensitivity guarantee this tree has not yet had occasion to exercise, not a new refusal aimed at work already done - round 2 re-walked the sharper subject — every commit whose diff moves a tombstone-shaped line — and moved **0** of those 125 verdicts either way Population on the judged surface today: **254** tombstone key lines across **66** files. ## The self-test is the thing that must be capable of failing - **269** cases at the merge base → **288** after round 1 → **298** now, exit **0**. - Round 1, shown red first: with the 19 new cases added and the matcher untouched, `✗ … 9 of 288 case(s) failed`, exit 1. - Round 2, shown red on the committed fix and then restored byte-identical (`git hash-object` before == after == the `HEAD` blob): - predicate reduced to "the value opens the call" ⇒ `✗ … 5 of 298 case(s) failed`, exit 1 — the three firing controls plus the two reader cases. - predicate set to the record's literal clause ⇒ `✗ … 1 of 298 case(s) failed`, exit 1 — the case pinning the 30 landed tombstones whose prescription arguments continue on the next line. The battery reads the FIRING half first, the way objectstack-ai#17300's is ordered, and brackets the decline on every side: a genuine key added beside a tombstone still fires with its own file:line; a value that merely MENTIONS the helper is not a tombstone; a value that CHAINS onto the helper is not one either; a tombstone-shaped line on a declared registry is still read as T4; un-retiring fires. One existing case was **replaced rather than re-spelled** — ``t('`retiredKey(` reads — 235 lines in the tree take it', … === 'T1')`` pinned exactly the branch this changes. Its replacement keeps what it was really pinning (the vocabulary, re-measured to 254) and records the new reading. ## Verification All readings below are at `4d12d84e12`, the final commit. - `pnpm check:pm-widening-tells` — **exit 0**, 298 cases - **31 derived / 31 run / 0 NOT-MEASURED / 0 UNRUN**, every family **exit 0**, reconciled by `dispatch-gates --ran` against a list recorded command-by-command with `status=$?` captured before any pipe: *"31 derived famil(ies) accounted for — 31 run, 0 NOT-MEASURED (a DERIVED zero — all 31 recorded an exit code and none of them is 3)"* - consumers of the changed module: `node scripts/pm/check-clause2-carriers.mjs --pair 18427` **exit 0**, `pnpm check:pm-prior-rulings` **exit 0** (99 cases) - `pnpm check:nul-bytes` **exit 0**; control-byte self-scan over the changed file: no hits (`grep -naP` exit 1, zero lines) - `check:scripts-symbol-anchors` caught a line-number citation in round 2's first draft of the header — a line number is not an anchor form; the anchor is now file-level, and the gate reads **exit 0** (3,412 anchors across 260 scripts) ### ESLint — a declared narrowing, measured rather than skipped The repo-wide `pnpm lint` is CI's run. This is the narrowing and the proof it excluded nothing: 1. **Checked population** read from ESLint's own config (`ESLint#isPathIgnored` over the 8,728 tracked files): **6,786**. 2. **Files linted here**: **1**, read from the `--format json` output — 0 errors, 0 warnings, exit 0. 3. **Invariance**: `eslint.config.mjs` states it *"never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file"*, so a one-file diff cannot move the verdict of any untouched file. ## Changeset — measured, not assumed `skip-changeset`. AGENTS.md: *"that label is for a diff that publishes nothing from any released package."* Readings: the root package `@objectstack/spec-monorepo` is `private: true` with no `files[]`; no package's `files[]` ships `scripts/`; and the positive control — the symbol `declaresRetiredKeyTombstone` — has **0** occurrences anywhere under `packages/`. Nothing published moves. Round 2 adds no export and no published key, so `Clause-②: no` still holds. ## Boundary this deliberately does NOT touch The prescription a tombstone carries is bare-string lines, so a prescription written on **one** line still reads as a T2 member (visible in probe B, whose `'x',` is synthetic). Measured: the **4** key-shaped single-line string prescriptions in this tree are all in one `*.test.ts` file, which is **off** the contract source surface, and the residual T2 population on the judged surface is **0 by direct simulation** over all 254 blocks — not by the false "every one of the 254 is multi-line" sentence round 2 removed. It is a different reading's card on the day that population is not zero. ## Acceptance notes - `Clause-②: no` holds and did not flip: repairing a false positive moves no published accept set, and the one new export (`declaresRetiredKeyTombstone`) is a script-local reader in a repo-root `scripts/` file that no package publishes. - *noted, not filed:* an ADDED parameter reaching `tellsInFile` can SPEND a T1 budget unit before objectstack-ai#17618's `inParameterList` decline is reached. Same ordering asymmetry this PR fixes for tombstones, on the parameter reading instead. **Carrier: none** — no queued card touches `inParameterList`. - *noted, not filed:* the record's own spelling census (148 + 106) is arithmetically unreachable from this tree; it is corrected here rather than filed, because the only artefact carrying it is the review comment this PR answers. **Carrier: this PR.** Round 2 by `session_01KB5PFtxuy1x3dcR5gxudx6`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17784
Clause-②: yes
Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115) — the per-file remediation of the #14478 duration-unit rule. This card owns exactly one row, in
packages/spec/src/system/tenant.zod.ts. The gate PR (#17635) is sequenced to land LAST and is untouched here.yes, not thenoruling A wrote: the PM corrected it on the card (comment5652103109) under the mechanical floor inreferences/contract-review.md— a new key on a published payload is alwaysyes— and a rename is a removal plus a new key.needs:contract-reviewis hung on card and PR; the changeset is gradedminoraccordingly, matching the four sibling duration-rename changesets of this same rule (#15677 · #15678 · #15679 · #15680), every one of which shipped asfeat(spec)!under## 17.4.0.What changed
SchemaLevelIsolationStrategy.performance.schemaCacheTTLnamed its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The.describe()thatcontent/docs/references/system/tenant.mdxrenders said "Schema cache TTL" and named no unit at all, so the reader of the published reference page could not tell 3600 seconds from 3600 milliseconds.performance.schemaCacheTTL: 3600performance.schemaCacheTtlSeconds: 3600Schema cache TTLSchema cache TTL in seconds3600The new spelling is
Ttl, notTTL, derived from how the suffixed family already spells itself on this tree rather than from the dispatch:cacheTtlSeconds11,ttlSeconds3,defaultCacheTtlSeconds1, and no key-positionTTLSecondsvariant anywhere.The kit, following the #15678 / #15679 shape the ruling names:
retiredKey()tombstone on the old spelling —tsctypes itneverand a value reaching the parse raises the rename prescription instead of being silently stripped (the nestedperformanceobject is not.strict())tenant-schema-cache-ttl-unit-in-keyand theRETIRED_KEYS_BY_MAJOR[18]rowsystem/SchemaLevelIsolationStrategy:performance.schemaCacheTTL, both asmigrations/entries/files withregistry.tsregenerated bygen:migration-registry(never hand-merged)stack.zod.tsdeclares no tenancy collection and a tenant isolation strategy is not a stored metadata row, so the chain has no seam that runs on it — the same readingtenant-timeouts-unit-in-keyrecorded for the two sibling keys on this same fileSchemaLevelIsolationStrategySchema: the refusal carries the rename prescription, the suffixed key parses at the magnitude the retired one carried with the same 3600 default, and the describe publishes the unitcontent/docs/references/system/tenant.mdxregenerated bygen:docs— all four rows move and the tombstone prescription renders in place of the old describeminorchangeset carrying the FROM to TO mapping and the ADR-0087 dispositionThe measurement this card was dispatched to produce
This is the first of six renames, so the mechanical clause-② reading matters for the other five.
node scripts/pm/check-widening-tells.mjs --declaration noover this PR's own diff — exit 4, exactly one tell:The tell is not on the rename. It is on the tombstone. Two synthetic probes separate the two halves on the same instrument:
retiredKey(key line with no paired removalSo the accounting is exact: the removed
schemaCacheTTL:line buys one T1 unit, the addedschemaCacheTtlSeconds:line spends it, and the addedschemaCacheTTL: retiredKey(line is the surplus. EveryretiredKey()retirement raises this, and the line it raises on is the one that makes the accept set strictly narrower — the key becomesnever. That is the same inversion this file's own header records for#17300(T2 on the retirement ledger's rows) and#17618(T1 on a narrowed in-shape key), one door further along. Filed as #17955; the diff was not reshaped to silence it and no gate was weakened.yesthis PR now declares rests on the contract-review floor (the rename really does add a key an author may write).check-clause2-carriers --pair 17954read exit 4 / C5 against the originalnoand reads exit 0 against the correctedyes.Verification
Heavy runs through
scripts/pm/os-verify-lock.sh, verdicts read from its ownVERDICTline; every gate's exit code captured before any pipe.pnpm --filter '@objectstack/spec^...' build— exit 0, empty closure (No projects matched):packages/spechas no workspace dependencies, so step ① is a documented no-oppnpm --filter @objectstack/spec build—VERDICT command-exit 0, run before every dist-reading gate and re-run after the last source editpnpm --filter @objectstack/spec test— 475 test files / 13507 tests passedpnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit+check:scripts-typecheck+check:test-typecheck)pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date aftergen:docs;check:authorable-surface,check:api-surface,check:migration-registry,check:spec-changesandcheck:upgrade-guideamong themcheck:authorable-surfacedoes not move for this key and that is correct: the ratchet records top-level keys per def and this one is nested underperformance(0 hits for the key acrossauthorable-surface/andauthorable-surface.base.json, against 4 for thesystem/MigrationPlan:lit control)check:duration-unit-keys(the gate as it stands onmain, not feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635's) — exit 0check:objectui-pin-citations— exit 0, 16 asserting pin citations match.objectui-shagit grepof the pinned objectui checkout at.objectui-sha=53ded82bf7a494f54e344e19099dbf00854b8694(re-read from this tree) —schemaCacheTTL0 occurrences across 6409 tracked files, beside lit controlsTTL112,Ttl11,tenant819,cacheTTL1 and a dark control at 0, so the zero is a readingGate set derived at the actual change set with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack; full per-gate exit codes, including the ones that could not run in this container, are in the round report on #17784.Acceptance notes
ttlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478 / spec:tenant.zod.tsidleTimeout / sessionTimeout publish a describe with no unit, while the JSDoc one line above says seconds #14519 registered a D3 semantic entry but noRETIRED_KEYS_BY_MAJORrow, while [#14478 stack 4/6]system/: the 15 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers;metrics.zod.tssizeneeds an honest name, not the mechanical one #15679's nested duration renames onsystem/metrics.zod.tsandsystem/cache.zod.tsregistered both. This card follows [#14478 stack 4/6]system/: the 15 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers;metrics.zod.tssizeneeds an honest name, not the mechanical one #15679 (the shape ruling A names) and registers both. Successor: whoever lands the remaining four rename cards in this epic, who meets the same choice.packages/spec/scripts/check-duration-unit-keys.tsand its self-test (PR feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635, sequenced last, red by construction), the five sibling files owned by spec: 3 duration key(s) inkernel/plugin-lifecycle-advanced.zod.tsname their unit only in JSDoc — #15939 Ruling A remediation (3 of the 21-row delta) #17780 spec: 1 duration key(s) inkernel/plugin-security-advanced.zod.tsname their unit only in JSDoc — #15939 Ruling A remediation (1 of the 21-row delta) #17781 spec: 4 duration key(s) insystem/logging.zod.tsname their unit only in JSDoc — #15939 Ruling A remediation (4 of the 21-row delta) #17782 spec: 5 duration key(s) insystem/metrics.zod.tsname their unit only in JSDoc — #15939 Ruling A remediation (5 of the 21-row delta) #17783 spec: 4 duration key(s) insystem/tracing.zod.tsname their unit only in JSDoc — #15939 Ruling A remediation (4 of the 21-row delta) #17785, andcontent/docs/releases/.⛔ Draft on purpose: the in-seat clause-② review this PR now declares is owed before it may turn ready or enqueue. That is the PM's step, not this round's.
Round report, with every reading above and its exit code: the
os-dev-reportcomment on #17784.Generated by Claude Code