Skip to content

feat(spec)!: tenant schemaCacheTTL carries its unit in the key name (#17784) - #17954

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-17784-tenant-schema-cache-ttl-unit
Sep 13, 2026
Merged

zhuangjianguo merged 4 commits into
mainfrom
claude/issue-17784-tenant-schema-cache-ttl-unit

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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.

⚠️ The declaration reads yes, not the no ruling A wrote: the PM corrected it on the card (comment 5652103109) under the mechanical floor in references/contract-review.md — a new key on a published payload is always yes — and a rename is a removal plus a new key. needs:contract-review is hung on card and PR; the changeset is graded minor accordingly, matching the four sibling duration-rename changesets of this same rule (#15677 · #15678 · #15679 · #15680), every one of which shipped as feat(spec)! under ## 17.4.0.

What changed

SchemaLevelIsolationStrategy.performance.schemaCacheTTL named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The .describe() that content/docs/references/system/tenant.mdx renders 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.

before after
authored key performance.schemaCacheTTL: 3600 performance.schemaCacheTtlSeconds: 3600
published describe Schema cache TTL Schema cache TTL in seconds
value + default seconds, 3600 unchanged

The new spelling is Ttl, not TTL, derived from how the suffixed family already spells itself on this tree rather than from the dispatch: cacheTtlSeconds 11, ttlSeconds 3, defaultCacheTtlSeconds 1, and no key-position TTLSeconds variant anywhere.

The kit, following the #15678 / #15679 shape the ruling names:

  • a retiredKey() tombstone on the old spelling — tsc types it never and a value reaching the parse raises the rename prescription instead of being silently stripped (the nested performance object is not .strict())
  • ADR-0087 D3 semantic entry tenant-schema-cache-ttl-unit-in-key and the RETIRED_KEYS_BY_MAJOR[18] row system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL, both as migrations/entries/ files with registry.ts regenerated by gen:migration-registry (never hand-merged)
  • no D2 conversion: stack.zod.ts declares 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 reading tenant-timeouts-unit-in-key recorded for the two sibling keys on this same file
  • pin tests on SchemaLevelIsolationStrategySchema: 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 unit
  • content/docs/references/system/tenant.mdx regenerated by gen:docs — all four rows move and the tombstone prescription renders in place of the old describe
  • a minor changeset carrying the FROM to TO mapping and the ADR-0087 disposition

The 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 no over this PR's own diff — exit 4, exactly one tell:

✗ T1 packages/spec/src/system/tenant.zod.ts:454 — a new key on a Zod object schema — the accept set gains a spelling an author may now write
    + schemaCacheTTL: retiredKey(

The tell is not on the rename. It is on the tombstone. Two synthetic probes separate the two halves on the same instrument:

probe exit tells
the rename alone (one key line removed, one added, one change block) 0 none — the #16943 REPLACEMENT budget absorbs it
an added retiredKey( key line with no paired removal 4 T1 on the tombstone line

So the accounting is exact: the removed schemaCacheTTL: line buys one T1 unit, the added schemaCacheTtlSeconds: line spends it, and the added schemaCacheTTL: retiredKey( line is the surplus. Every retiredKey() retirement raises this, and the line it raises on is the one that makes the accept set strictly narrower — the key becomes never. 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.

⚠️ Read the two readings apart, because they agree on the verdict for different reasons: the mechanical tell is a false positive on the tombstone line, while the yes this PR now declares rests on the contract-review floor (the rename really does add a key an author may write). check-clause2-carriers --pair 17954 read exit 4 / C5 against the original no and reads exit 0 against the corrected yes.

Verification

Heavy runs through scripts/pm/os-verify-lock.sh, verdicts read from its own VERDICT line; every gate's exit code captured before any pipe.

  • pnpm --filter '@objectstack/spec^...' build — exit 0, empty closure (No projects matched): packages/spec has no workspace dependencies, so step ① is a documented no-op
  • pnpm --filter @objectstack/spec buildVERDICT command-exit 0, run before every dist-reading gate and re-run after the last source edit
  • pnpm --filter @objectstack/spec test475 test files / 13507 tests passed
  • pnpm --filter @objectstack/spec typecheck — exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck)
  • pnpm --filter @objectstack/spec check:generatedall 15 generated artifacts up to date after gen:docs; check:authorable-surface, check:api-surface, check:migration-registry, check:spec-changes and check:upgrade-guide among them
  • check:authorable-surface does not move for this key and that is correct: the ratchet records top-level keys per def and this one is nested under performance (0 hits for the key across authorable-surface/ and authorable-surface.base.json, against 4 for the system/MigrationPlan: lit control)
  • check:duration-unit-keys (the gate as it stands on main, not feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635's) — exit 0
  • check:objectui-pin-citations — exit 0, 16 asserting pin citations match .objectui-sha
  • cross-repo, per the ruling's execution note and AGENTS.md post-task step 4: git grep of the pinned objectui checkout at .objectui-sha = 53ded82bf7a494f54e344e19099dbf00854b8694 (re-read from this tree) — schemaCacheTTL 0 occurrences across 6409 tracked files, beside lit controls TTL 112, Ttl 11, tenant 819, cacheTTL 1 and a dark control at 0, so the zero is a reading

Gate 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

⛔ 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-report comment on #17784.


Generated by Claude Code

`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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f62787c033e5f36bc02a2eafff62ff84d73c9b70packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2105eeb378bef4ec650d81fbc904b2305bcaa5bf — the merge of head ee19e715606d215e294d04c72bbc23bbb946ef2c into base f62787c033e5f36bc02a2eafff62ff84d73c9b70, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

Copy link
Copy Markdown
Collaborator

PM status on the two red checks — both diagnosed, both this PR's, fixes in flight

Epic PM for the #15939 subtree, session_015c5G6TmpMKgnusmTpD7Ntt, 2026-09-13T08:20Z. Read from the job logs, ⛔ not from the round's self-report.

1. Check Changeset⚠️ triggered by a PM action, and it exposed a real error

I hung needs:contract-review on this PR at ~08:01 after correcting the card's clause-② declaration to the affirmative (correction comment 5652103109, reasoning in 5652094166). The gate read the carrier and said:

⛔ This PR declares clause-② YES, and it grades NO package whose packages/**/src/** it moves at minor or above. … @objectstack/spec: patch

This is not "a PM label broke a good PR". The patch grading was already wrong, and nothing was going to say so while the declaration read the other way. Measured by me on origin/main in packages/spec/CHANGELOG.md, every sibling of this card under ## 17.4.0 (a minor bump):

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 "patchfollowing 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>

Copy link
Copy Markdown
Collaborator

Contract review

Head sha reviewed: 0183e54fc1add770986077b260f44715c02eeae5 — re-read from origin at 2026-09-13T09:02:56Z after every measurement below: unchanged since the review opened. Merge base with main: bd25e897dc (origin/main at 884e8347d1). PR still draft; needs:contract-review on PR and card #17784; 7 files, none on a governed surface, content/docs/releases/ untouched.

Every tree reading below was taken in a worktree checked out at that sha, with @objectstack/spec built through bash scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 173 s). Judged from the diff and by parsing, not from the PR body.

① derived judgments — accept set and public surface

  1. performance.schemaCacheTTL: <any defined value> moves accept → refuse, on SchemaLevelIsolationStrategySchema and on the isolated_schema arm of TenantIsolationConfigSchema. Parsed: {strategy:'isolated_schema', performance:{schemaCacheTTL:3600}} → REFUSED, one issue at path performance.schemaCacheTTL, code invalid_type, message = the rename prescription (same for value 1, via the union, and with both keys present). Absence (undefined) still parses. PR characterisation ("raises the rename prescription instead of being silently stripped") — right. The tsc half is also measured: z.input types the old key undefined; a probe assigning 3600 fails TS2322: Type 'number' is not assignable to type 'undefined' at exactly that line, while the new key and undefined compile. The PR says "types it never" — effect identical, wording fine.
  2. performance.schemaCacheTtlSeconds: n moves from accepted-and-stripped to accepted-and-honoured. Under the merge-base schema the new pin fails with expected undefined to be 7200 — the old (non-strict) object silently dropped the key and emitted the 3600 default; at head it carries 7200. So on the pure accept/reject axis nothing is newly accepted (that payload parsed before) and the change is a strict narrowing; what widens is the public surface — a new authorable spelling with effect and a new property on the exported SchemaLevelIsolationStrategy/TenantIsolationConfig types. The PR's "the accept set gains a spelling an author may now write" is right on the surface limb, imprecise on the accept-set limb; both limbs read clause-② yes (③ F1).
  3. Default 3600 unchanged (performance: {}schemaCacheTtlSeconds: 3600). Right.
  4. Published describe Schema cache TTLSchema cache TTL in seconds; tombstone describe [REMOVED] … added. Rendered twice each on the reference page (content/docs/references/system/tenant.mdx:154,178-179,281,306-307), consistent with the sibling idleTimeout/sessionTimeout rows (:63,:339,:417). Page is generator output — check:generated on the fresh dist: all 15 artifacts up to date. Right.
  5. Non-strict control: a typo key schemaCacheTtl: 7200 is still accepted-and-stripped (pre-existing, untouched). That is exactly why a bare deletion would have been the ADR-0049 silent-strip failure; the tombstone closes that door for the old spelling only. Right.
  6. Exports: none added/removed — packages/spec/api-surface/system.json:561-563 unchanged; git diff --stat on api-surface, authorable-surface*, json-schema.manifest, spec-changes.json, docs/protocol-upgrade-guide.md is empty. Correct by construction: those ratchets record existence (export names, top-level keys, def names), not nested shape — authorable-surface/system.json:960-963 lists only the four top-level keys; packages/spec/scripts/build-schemas.ts:849-855 reads schema.properties one level deep. PR's "no row moves, and that is correct" — right.
  7. ADR-0087 ledger: +1 semantic entry in step18 (registry.ts:10423-10457), +1 row under RETIRED_KEYS_BY_MAJOR[18] (registry.ts:13187). gen:migration-registry re-run → git diff --quiet exit 0 (207 semantic / 169 retired-key / 178 retired-def); the entry file's string equals the row (1 = 1). Generated, not hand-merged. Upgrade guide / spec-changes.json do not move because protocol 18 is the open major (guide ends at "Protocol 16 → 17"), same as every sibling entry in step18.
  8. No D2 conversion — right for this repo, by measurement: stack.zod.ts has no tenant collection (0 hits), MetadataTypeSchema (kernel/metadata-plugin.zod.ts) has no tenant type (comments only), conversions/registry.ts has no tenant entry, and every applyConversionsToStoredItem seam (packages/metadata/src/loaders/database-loader.ts:825, packages/objectql/src/plugin.ts:2128, packages/metadata-protocol/src/protocol.ts:4675) is keyed by metadata type. Zero runtime readers of TenantIsolationConfig/SchemaLevelIsolationStrategy outside spec source (hits are CHANGELOG, declaration-map, export-origins, a name test). Outside this repo (cloud): NOT MEASURED — bounded because a hypothetical stored row would fail loudly with the prescription, never strip silently, and the two earlier renames on this file (tenant-timeouts-unit-in-key, shipped in 17.4.0) took the identical route.
  9. Consumers: schemaCacheTTL outside packages/spec/** at main = 4 rows, all in the generated tenant.mdx. Pinned objectui at 53ded82bf7… re-grepped: schemaCacheTTL 0, schemaCacheTtlSeconds 0, lit TTL 112 / Ttl 11 / tenant 819, dark 0, over 6409 tracked files. No consumer-package test owed. Right.
  10. Nothing else on the def moves — the source diff is confined to the performance object (tenant.zod.ts:441-462). check:duration-unit-keys (main's gate) exit 0, 211 keys, zero offenders.

② semver grading — minor, feat(spec)!

  • What the diff does: removes a spelling from a published payload (breaking for any author who wrote it) and adds one. Strict semver says major.
  • Why minor is nonetheless the correct grade here: scripts/check-changeset-no-major.mjs:36-88 records a launch-window convention with a written GA end condition — major is refused (no .changeset/pre.json, so the guard is armed) and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, "not by the level"; .github/workflows/pr-automation.yml:717-724 restates it. Both carriers are present: **BREAKING** (changeset line 9), <!-- adr-0087: registered tenant-schema-cache-ttl-unit-in-key --> (line 7; gate exit 0, "new here"), FROM→TO diff block (lines 20-25). The ADR-0087 machinery does not make the change non-breaking — it makes it survivable and declared, which is what the convention requires in place of the bump level.
  • Why not patch: with Clause-②: yes the level axis (check-changeset-no-major.mjs:7-16) requires ≥ minor on the moved package; locally the axis is N/A (no PR payload), CI's Check Changeset passed on this head. Independently, a breaking change graded patch would tell an upgrader nothing.
  • Were the siblings right? Yes — for the same written reason, not as precedent. packages/spec/CHANGELOG.md:124,675,912,1804,2764 are all feat(spec)! under ## 17.4.0 (line 3); they corroborate, they are not the argument. Ruling A's patch was a sizing slip contradicted by its own "[#14478 stack 3/6] kernel/: the 14 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers (runtime-emitted measurements included) #15678/[#14478 stack 4/6] system/: the 15 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers; metrics.zod.ts size needs an honest name, not the mechanical one #15679 shape" and by the gate; correction 5652120294 is correct.
  • Observation, not a defect of this PR: the tombstone says "in @objectstack/spec 17" (the package major it ships in, matching the sibling tombstones at tenant.zod.ts:575-576,706) while the ledger rows sit under protocol 18 (140 rows under 18: vs 29 under 17:); build-schemas.ts:846 CURRENT_MAJOR reads 17 from the package version. Pre-existing, tree-wide.

③ boundary flags

Implemented-by: claude/issue-17784-tenant-schema-cache-ttl-unit
Reviewed-by: session_015c5G6TmpMKgnusmTpD7Ntt

(Disclosed, not hidden: dispatcher, PM and reviewer are one session — 席内至档 as recorded on #15939 comment 5652016540; the pair reader flags the correction as declared by the same session as the claim.)

PASS — every accept-set and surface change is measured and matches the diff's characterisation, the registry and reference page are the generators' byte-identical output, the tombstone refuses on both the def and the union with the prescription, and both PM corrections are correct on the governing text.


Provenance and tier verification — the seat's own record, not the reviewer's

Adopted verbatim by the epic PM seat for #15939, session_015c5G6TmpMKgnusmTpD7Ntt, 2026-09-13T09:12Z. The fuse permits adoption or voiding, ⛔ never rewriting, abridging or polishing.

  • Served tier, from the harness-stamped transcript, ⛔ not a self-report: 106 messages stamped "model":"claude-fable-5-1", zero other values — exact match to CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs:10507). Discriminating control: the authoring round's transcript over the same grep reads 242 × "model":"claude-opus-5".
  • The review was run in a context-isolated seat fed only the card, the standing rulings and the PR — ⛔ never the dispatch order or this seat's conclusions. It nevertheless inherits this session id (席内至档), which it discloses itself above rather than leaving to be inferred.

Two things this review corrected that the round did not have, recorded because they are why the layer exists:

  1. ①.2 — on the pure accept/reject axis the new spelling was already accepted-and-stripped by the non-strict object, so that axis is a strict narrowing; the widening is on the public surface. The round's phrasing was right on the surface limb and imprecise on the accept-set limb. The declaration is unaffected — both limbs read the affirmative.
  2. F4 — for a nested key the RETIRED_KEYS_BY_MAJOR row is inert on all three gate consumers, proven by ablation (a fabricated nested row passes check:authorable-surface exit 0 while a live top-level key is refused exit 1, registry restored byte-identical). ⇒ This falsifies the cost I asserted in [finding] a nested RETIRED_KEYS_BY_MAJOR row is unverifiable — check (b2) passes a fabricated one, so a typo'd nested retirement registers silently and forever #17969, which I am correcting on that card rather than leaving to stand. Its escalation — a typo'd nested row passes silently because (b2) cannot verify it — is the real defect in that area and is being carried there.

⇒ The settlement recorded on #15939 stands unchanged: the four remaining rename cards register both. The review's own words: "registering is harmless, consistent with the 36 other nested rows under 18 and the #15679 shape … keep it."

epic PM for #15939 · session_015c5G6TmpMKgnusmTpD7Ntt · 2026-09-13T09:12Z


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 13, 2026 09:52
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Carrier cleared — provenance. ⚠️ And the queue guard caught a step I skipped.

Epic PM for #15939, session_015c5G6TmpMKgnusmTpD7Ntt, 2026-09-13T10:04Z.

What happened

This PR was dequeued at 10:00:11Z, CI_FAILUREGoverned Surface Queue Guard, run 34750497713, exit code 6. The guard's own message says what it is for:

On PASS that same seat strips the carrier from BOTH carriers, cites the record, and re-enqueues … ⚠️ This leg reads the LABEL, not the verdict. A carrier stripped before any PASS was on record is indistinguishable here from one that was never hung.

⇒ ⛔ My error. references/contract-review.md orders it 「PASS ⇒ 同席剥标并引记录、ready、auto-merge」 — strip, then ready, then enqueue. I went PASS → record → ACCEPT → ready → enqueue and skipped the strip, so a PR still flagged for contract review reached the queue. The guard refused it, which is exactly its job. ⛔ Nothing about this is a defect in the PR, and ⛔ nothing about it is a reason to edit the guard.

The record this strip cites

  • In-seat clause-② contract review: PASS — comment 5652364678, posted 09:07:15Z.
  • Head reviewed: 0183e54fc1add770986077b260f44715c02eeae5.
  • Served tier verified from the harness-stamped transcript, ⛔ not self-reported: 106 × claude-fable-5-1, zero other values, against a 242 × claude-opus-5 discriminating control. CONTRACT_REVIEW_TIER = claude-fable-5-1.

⚠️ The head moved after the review — measured, not waved past

The review read 0183e54fc1; the current head is ee19e71560 (the origin/main merge commit). contract-review.md says a moved head is a reason to re-hang, so this is not something to leave implicit.

Measured: git diff 0183e54fc1 ee19e71560 restricted to this card's authored paths returns a single file — entries/semantic/18.cache-warmup-scheduled-strategy-retired.ts (+63), which is #17638's entry arriving from main, caught by the entries/** glob and not this card's work. tenant.zod.ts, tenant.test.ts, tenant.mdx and the changeset are byte-identical to what the reviewer read. Control: the two heads differ by 61 files / +5340 overall, so the restricted empty result is a reading and not a broken command.

⇒ The review's subject is unchanged. The head moved by absorbing main, ⛔ not by re-authoring, so the PASS still binds. Corroborating: check-clause2-carriers --pair 17954 reads exit 0 on the current head, and the merge itself was verified drop-free both by the round (set difference over every registry row: 646 → 648, zero lost) and independently by this seat (five probes from main's window, identical counts; 15554 → 15600 = +46, closing against the diff).

Action

needs:contract-review stripped from both carriers — this PR and card #17784 — in the same pass, then re-enqueued. ⛔ Not stripped to get past the check: the verdict was on record 53 minutes before the guard ran.

epic PM for #15939 · session_015c5G6TmpMKgnusmTpD7Ntt · 2026-09-13T10:04Z


Generated by Claude Code

Merged via the queue into main with commit fc28c1d Sep 13, 2026
40 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-17784-tenant-schema-cache-ttl-unit branch September 13, 2026 10:31
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: 1 duration key(s) in system/tenant.zod.ts name their unit only in JSDoc — #15939 Ruling A remediation (1 of the 21-row delta)

2 participants