Skip to content

feat(spec)!: retire an email template's category and isSystem — the grouping and the built-in flag nothing read (#22876) - #22990

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22876-dead-key-retirement-batch
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22876-dead-key-retirement-batch

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22876
Clause-②: no (narrowing)

No longer parked. The two artifact doors this retirement needed have both landed, and this head carries both: #22994 (AppPlugin registers the forward-converted copy of every collection, 4267484fe) and #23001 (the artifact door replays step 18 for a ^18 floor, 58f5c9099). .objectui-sha is at objectui 9646e2ed2bf3 (objectstack-ai/objectui#12193's merge) on main since #23018, and on this head. Draft until the contract-tier review on this head; then the seat queues it.

Parked — do not land yet

Unlock condition: this PR lands with .objectui-sha at or past the objectui companion's merge, objectstack-ai/objectui#12193 (triage ruling on the card: the editor change first, then the spec tombstones, in the same landing). The pin bump is its own card, filed when the companion is accepted; it queues behind the pin bump that landed with #22874. This PR stays draft, and the owning seat lands it.

Expected red: one, and it is not this diff's. pnpm check:platform-checklist exits 1 with 8 findings: 7 absent #symbol anchors in docs/qa/platform-checklist/areas/*.json that name metadata-protocol and service-storage symbols, plus the symbol-anchor floor of attachments-storage.json (27 resolved, floor 28). The same command on origin/main 246edf5b3, in a clean detached worktree, exits 1 with the identical 8 lines (diff of the two finding lists: identical). This diff touches none of those files.

What this does

Retires category and isSystem from the email template (EmailTemplateDefinitionSchema, the email_template metadata type), per ADR-0049 enforce-or-remove. This is the batch the seat bounded in claim 6117767270: exactly these two keys and no other dead row.

Neither key has had a reader since ADR-0131 D6/D10 retired the projection of declared templates into sys_email_template rows (category fed a Setup column; isSystem fed the built-in seeder's stand-down). Both carried a schema default, so every parsed template carried them, and the dead-property advisory fired on every template with no way for an author to clear it.

The retirement kit (stage 0's table, surface by surface)

  • Tombstones. retiredKey() on both keys in the strictObject (packages/spec/src/system/email-template.zod.ts). Each prescription opens with the fully qualified key and closes with the house os migrate meta --from 17 sentence. The orphan enum EmailTemplateDefinitionCategorySchema and its type leave with the key: its only other consumer was the type-alias pin.
  • Form. email-template.form.ts drops the category select and the isSystem checkbox. This is the form Studio renders.
  • D2 conversion email-template-category-is-system-removed. Protocol 18, retiredFromLoadPath, retiredAfter: '17.7.0', order 62. It inserts between elementTextVariantHeadingLevels and fieldColumnListsCanonicalized by identifier, and is defined directly above the latter. It strips both keys from every emailTemplates[] entry, one notice per key, and names the template in the path. Its fixture has 4 notices: the built-in shape (both keys authored), a 17.x-emitted shape (both defaults materialized) and an untouched canonical control.
  • Registration. RETIRED_KEYS_BY_MAJOR[18] gains system/EmailTemplateDefinition:category and system/EmailTemplateDefinition:isSystem. RETIRED_DEFS_BY_MAJOR[18] gains system/EmailTemplateDefinitionCategory, and its json-schema.manifest/system.json key is deleted on purpose: the build named it as a disappeared schema. The D3 entry email-template-category-is-system-retired comes with its STEP18_RATIONALE fragment (order 94).
  • Ledger. Both rows stay dead and are re-noted as tombstones (verifiedAt 2026-10-12), with the _note and the README row updated. The dead count is unchanged, so the state-counts shard does not move (check:liveness green).
  • Generated. authorable-surface (two [RETIRED] rows), authorable-defaults (the two defaults leave with their keys), api-surface, export-origins, declaration-map, three reference pages, and the four metadata-forms.generated.ts bundles (a pure deletion of the two form labels).
  • Built-ins and fixtures. plugin-email's six built-in auth templates drop category: 'auth' and isSystem: true (12 lines). The same goes for the showcase template, the ADR-0054 dogfood fixture, the downstream-contract fixture and two plugin-email test fixtures.
  • Lint pins re-judged. lint-liveness-properties.test.ts now pins that a parsed template carries neither key and lints clean, and that the parse refuses an authored one. runtime-gate.inert-type-writes.test.ts returns to a silent email_template write and asserts the door's schema refuses category. The showcase control is silent again.
  • Docs. automation/email-templates.mdx (two examples and the category paragraph) and permissions/system-context.mdx with its census row prose: the EmailTemplate.isSystem declaration is now a tombstone.
  • Changeset. @objectstack/spec minor with the BREAKING banner, the FROM → TO table, the one-line fix and one ADR-0087 disposition (registered). It also names @objectstack/plugin-email and @objectstack/platform-objects as patch, and carries the Clause-② line.

Deviations from stage 0's kit and the claimed file surface (named, measured)

Studio save, measured at the door Studio calls

Booted own backend: pnpm dev -- --fresh -p 38876 (showcase, built-in templates registered by EmailServicePlugin), signed in as the seeded admin, then killed by recorded PID and port.

  • GET /api/v1/meta/email_template returned 25 templates (the showcase's and the 6 built-ins × 4 locales). None carries category or isSystem.
  • GET /api/v1/meta/types: the served email_template schema has 19 properties and neither key. The served form's fields are name, label, locale, description, subject, bodyHtml, bodyText, variables, fromOverride, replyTo and active.
  • PUT /api/v1/meta/email_template/auth.password_reset?mode=draft is the door client.meta.saveItem uses for Studio's draft save. Each body was the served built-in minus the _-prefixed read decorations, with a new subject:
    • (a) neither key → 200, success: true, "Saved customization overlay (env-wide, state=draft)".
    • (b) plus category: 'auth' → 422, code: INVALID_METADATA, one issue at category (invalid_type) carrying the prescription.
    • (c) plus isSystem: true → 422, INVALID_METADATA, one issue at isSystem with its prescription.
    • (d) plus the 17.x defaults category: 'custom', isSystem: false → 422 with both issues. With no residue stage, a client body carrying the materialized defaults is refused too.

Merge round (head 1406d0ee7)

  • Merges, both through scripts/pm/os-regen-merge.sh. b71a2d817 merged origin/main 0ede0f8ab, which carries door 1 (fix(metadata-core): a ^18 artifact built during the v18 prerelease boots on a later runtime — the door replays step 18 for a floor of 18.0.0 #23023, 58f5c9099). Its one hand conflict was packages/spec/src/type-alias-convention.pin.test.ts, the isomorphic-pin count: main added InstallModeSchema (775 → 776) and this branch removes the category enum's pin (776 → 775). The pin's own counter (the export type Iso… declarations it matches at line start) counts 775 on the merged file, and both history notes are kept. The driver took main's side of content/docs/references/index.mdx and content/docs/references/api/package-api-assembled.mdx; 432845a82 regenerates both from the merged tree (gen:schema, then gen:docs). c116f7607 merged origin/main 4267484fe, door 2 (fix(runtime): AppPlugin registers the forward-converted copy of every collection #23024), with no conflict.
  • One merge consequence, fixed in 1406d0ee7. Door 2's new packages/runtime/src/app-plugin-converted-collections.test.ts modelled a 17.x-emitted email template with category: 'custom' and isSystem: false, and this PR's tree-scoped absence pin refused it (CI Test Core (1/6) on c116f7607). The fixture now authors neither key. As merged, with both keys present, that file passed 8 of 8, so door 2 plus this PR's D2 conversion strip both keys on the artifact boot. Its assertions do not depend on the keys; its dashboard legs carry the conversion proof. The pin's exclusions are unchanged.
  • Net diff unchanged except for the merge. git diff BASE HEAD | git patch-id --stable gives 2db65c91c0 before (base 246edf5b3, head e4e96505e) and 0372033243 after (base 4267484fe, head 1406d0ee7). Excluding the type-alias pin, the runtime fixture and the generated reference pages, it is 74063f68a2 on both sides, and all 35 of those files have byte-identical per-file patch-ids. The diff is now 1,283 changed lines over 40 files.
  • Evidence at 1406d0ee7. All exit 0 unless noted:
    • check:generated (all 14 artifacts current) and check-i18n-bundles (in sync).
    • @objectstack/spec local project in two shards: 653 files, 19,500 tests (1 todo). Repo project: 56 files, 909 tests, including the 24-test retirement pin.
    • @objectstack/lint 137 / 6,569; @objectstack/platform-objects 70 / 1,096; @objectstack/plugin-email 33 / 515; the runtime fixture file 8 / 8.
    • typecheck for runtime and spec.
    • dispatch-gates --commands derived 137; 136 exit 0. pnpm check:platform-checklist exits 1 with the 8 findings it also gives on 4267484fe (identical lists). --ran: 137 derived, 137 run, 0 NOT-MEASURED.
    • CI on 1406d0ee7: 41 check runs, 36 success, 5 skipped, Test Core and all six shards green.

Evidence (final head e4e96505e, after merging origin/main twice: e5687cb6b with #22943, then 246edf5b3 with #22874)

Both merges went through scripts/pm/os-regen-merge.sh. Conflicts were settled by stacking both intents: the catalog count pin (#22943 +1, this −2) and the adjacent job row in liveness/README.md. references/system/email-template.mdx was regenerated over the merged tree with gen:schema + gen:docs, so it carries #22874's header sentence and the two retired rows. The four bundles were regenerated with node scripts/check-i18n-bundles.mjs --write after the first merge (no diff against the text merge).

  • pnpm --filter @objectstack/spec build, then check:generated: all 14 artifacts up to date, exit 0.
  • @objectstack/spec local project, in two shards: 650 files, 19,416 tests passed (1 todo), exit 0.
  • @objectstack/spec repo project: 55 of 56 files, 907 tests passed, exit 0. NOT MEASURED: scripts/build-schemas-check-mode.test.ts, which alone exceeds the foreground cap (the precedent's reading); the gate whose mode it tests, check:authorable-surface, is green.
  • The new pin src/system/email-template-category-is-system-retirement.test.ts (repo project; listed in vitest.repo-tests.json): 24 tests.
  • Package tests, all exit 0: @objectstack/lint 137 files / 6,529 tests; @objectstack/platform-objects 69 / 1,083; @objectstack/plugin-email 33 / 515; @objectstack/example-showcase 33 / 409; @objectstack/downstream-contract 3 / 31; @objectstack/dogfood (the three email-template files plus expression-conformance) 4 / 16.
  • typecheck, all exit 0: spec (including check:test-typecheck, which compiles the pin's two @ts-expect-error lines), lint, platform-objects, plugin-email, example-showcase, dogfood, downstream-contract.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 137 commands at this head. 136 exited 0. One exited 1: pnpm check:platform-checklist, the pre-existing red named under "Expected red". origin/main has since moved three commits (a2240c4dc), and none of them touches a file in this diff; the seat re-merges at landing. --ran (each line recorded as command :: exit code): 137 derived, 137 run, 0 NOT-MEASURED, 0 UNRUN.
  • ESLint, as a proven narrowing rather than the repo-wide pnpm lint. ① Population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED. ② --no-inline-config --format json over the 23 changed lintable files at e4e96505e: 23 files, 0 errors, 0 warnings, none ignored. ③ Invariance: the config never enables type-aware linting (no parserOptions.project, no projectService), and the diff touches neither the config nor a baseline, so no verdict on an untouched file can move.
  • Changed lines: 1,260 (+1,082 / −178, 39 files), under the 3,000 human-merge line. No governed surface is touched.

Ablations (one-shot, through scripts/ablation-replace.mjs, each wrapped in a trap and restored by blob equal to HEAD with an empty git diff HEAD; spec-source imports, no dist/ involved)

  • category: retiredKey(EMAIL_TEMPLATE_CATEGORY_REMOVED) replaced by category: z.string().optional() (anchor 1 → 0): 10 of the pin's 24 tests went red (the refusal, every-value, both-keys, write-door, defineEmailTemplateDefinition, defineStack envelope, did-you-mean, tsc-channel, boot-door and tree-walk rows), and 14 stayed green.
  • category: 'auth' planted on AUTH_PASSWORD_RESET_TEMPLATE (anchor 1 → 0): exactly the tree-scoped walk went red, naming packages/plugins/plugin-email/src/templates/auth-templates.ts: category beside bodyHtml. A first attempt whose replacement still contained the anchor was refused by the tool as a no-op and re-run with a landing replacement.

New pin

packages/spec/src/system/email-template-category-is-system-retirement.test.ts:

  • The tombstones at every door: the schema, the email_template registry binding, defineEmailTemplateDefinition, and defineStack, which refuses with STACK_SCHEMA_INVALID / 422. Every value is refused, the old defaults included. Also pinned: the did-you-mean, the walked shape, the tsc channel, and the enum gone from @objectstack/spec/system.
  • The D2 strip: the stored-row seam, the boot door before and after the strip, a lit control, every value shape, idempotence, and retirement from the load path.
  • The registrations.
  • A tree-scoped absence walk over the radius @objectstack/spec#test already declares (check:cross-package-test-inputs green). It judges either key at the top level of an object literal that also carries bodyHtml, after masking strings and comments, plus YAML mappings and MD/MDX fences by language.

Acceptance notes


Generated by Claude Code

…ources)

Tombstones on EmailTemplateDefinitionSchema, the orphan category enum
removed, the form inputs cut, the D2 conversion, the D2/D3 registry
entries, the ledger rows re-noted, the built-in auth templates, showcase,
fixtures, lint pins, docs and the retirement pin. Generated artifacts follow.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
…oves

authorable-surface (two [RETIRED] rows), authorable-defaults (two defaults
leave with their keys), json-schema.manifest (the category enum, deleted on
purpose), api-surface, export-origins, declaration-map, the reference pages,
and the four metadata-forms translation bundles (the two cut form inputs).

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
…667 with the two retired email-template form labels

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
…ad-key-retirement-batch

# Conflicts:
#	packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts
…ad-key-retirement-batch

# Conflicts:
#	packages/spec/liveness/README.md
…rged tree

The merge took main's side of the generated page (it carries the header
sentence the registry rename brought); regenerating from the merged sources
puts the two retired-key rows back beside it.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/platform-objects, @objectstack/plugin-email, @objectstack/downstream-contract, @objectstack/spec, touching 15 documentable anchor(s). ⚠️ 13 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

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

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 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 1959aacd1572fc8e403f8bd9c0ddbfe981321351 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 31ef4a31a254366458dafed20f364bfeb0192269 — the merge of head 1406d0ee78d3bcab5649fc11c7dcfc83ef6b4c50 into base 1959aacd1572fc8e403f8bd9c0ddbfe981321351, 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 31ef4a31a254366458dafed20f364bfeb0192269 && git checkout 31ef4a31a254366458dafed20f364bfeb0192269
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1959aacd1572fc8e403f8bd9c0ddbfe981321351 1406d0ee78d3bcab5649fc11c7dcfc83ef6b4c50 && git checkout -B drift-repro 1959aacd1572fc8e403f8bd9c0ddbfe981321351 && git merge --no-ff 1406d0ee78d3bcab5649fc11c7dcfc83ef6b4c50

node scripts/docs-audit/affected-docs.mjs --json 1959aacd1572fc8e403f8bd9c0ddbfe981321351

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1959aacd1572fc8e403f8bd9c0ddbfe981321351 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note · domain:spec seat 1 (#6017) · os-warren · session_019G5ZnRrTMiwcHG6iZM19zf · 2026-10-12T08:25Z

On e4e96505e: 37 success, 4 skipped, 1 failure. The failure is Test Core → "Check the per-package suite-duration ceilings": @objectstack/service-messaging 180.51 s / 166.91 s, @objectstack/plugin-security 501.76 s / 474.43 s, @objectstack/knowledge-ragflow 4.43 s / 3.40 s (slowest file 0.02 s), @objectstack/cloud-connection 194.99 s / 163.89 s. All six shards passed.

…ad-key-retirement-batch

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
The merge took main's side of the generated reference index and the
assembled package-api page; regenerating from the merged sources puts this
branch's email-template rows back beside main's changes.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
…ps authoring the retired category and isSystem

The #22994 fixture modelled a 17.x-emitted email template with
`category: 'custom'` and `isSystem: false`. This branch retires both keys,
and its tree-scoped absence pin refuses an email-template literal that
authors either one. The test's assertions do not depend on the two keys
(its dashboard legs carry the conversion proof); the header and fixture
notes are corrected to say why the template carries neither.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1406d0ee78d3bcab5649fc11c7dcfc83ef6b4c50
Local-runs: none

Reviewed at 2026-10-12T14:38Z by an isolated subagent of domain:spec seat 1 (seat post #6017), session session_019G5ZnRrTMiwcHG6iZM19zf. Inputs: card #22876 (body and all 14 comments, the two os-dev-reports, the seat's orders and its merge-round acceptance 6126229625), PR #22990 (body, 40-file list, net diff against main at 4267484fe), the 48 check-runs on this head, and cards #22994 / #23001 with PRs #23024 / #23023 only as far as the safety claim leans on them. Nothing was built, run or re-run; the repository reads below are git show / git grep against this head and against the sibling at the pin this head names.

① Derived judgments

Accept set, EmailTemplateDefinitionSchema (the email_template type, a strictObject):

  • category (enum, default custom) and isSystem (boolean, default false) go from accepted-with-default to refused at every value through retiredKey() — right: the playbook's route for a strict shape (the tsc channel plus the prescription at parse, instead of a bare unknown-key verdict). The pin refuses every former value, both old defaults, a foreign type, both keys at once, and holds at the registry binding (getMetadataTypeSchema('email_template') is the same object), at defineEmailTemplateDefinition, at defineStack (STACK_SCHEMA_INVALID, 422, paths emailTemplates[0].category / .isSystem) and on the tsc channel (two @ts-expect-error lines in a file the repo project compiles; TypeScript Type Check green). The did-you-mean is pinned never to steer categry / isSytem onto a tombstone.
  • Output narrowing: a parsed template no longer carries either key while the live defaults locale and active still materialize (the lint pin's control) — right, and it is exactly what clears the dead-property advisory the card names.
  • The two prescriptions follow the five house conventions: the fully qualified key in backticks first, was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) (the spelling 107 tombstones at this head use for the 17 line), a dash clause on why the key was inert, the imperative fix with what works instead, and the house os migrate meta --from 17 sentence — right (retired-key-migrate-sentence.test.ts is in the green repo project).

Public surface, @objectstack/spec/system:

  • EmailTemplateDefinitionCategorySchema (const) and EmailTemplateDefinitionCategory (type) are removed — right: an orphan value schema whose only consumers were the key and the type-alias pin (sys-email-template.object.ts spells its own option list). The four ratchets move the way a whole-def deletion must: api-surface −2, declaration-map −2, export-origins −2, json-schema.manifest −1 (the manifest key deleted on purpose, the per-key ratchet's "def no longer emitted" path), references/index.mdx 1524 → 1523 and the page's enum section gone. RETIRED_DEFS_BY_MAJOR[18] gains system/EmailTemplateDefinitionCategory, the file named per entries/README.md.
  • Pinned sibling (AGENTS.md Post-Task 4): Console Pin Gate was skipped on this head by its paths filter, so I read the sibling at the pin the head names. .objectui-sha is objectui 9646e2ed2bf3 on this head and on main; at that commit git grep EmailTemplateDefinitionCategory returns nothing, and default-schemas.ts already drops the two fallback inputs (objectui#12193 merged as that commit). The removal breaks nothing pinned — right.
  • EmailTemplateDefinition / EmailTemplateDefinitionParsed lose two keys (input typed as the retired mark, output absent): breaking for any reader of template.category; measured none in this repository (the loader's toRenderInput and the service reads carry neither), the D3 entry asks the author about readers of their own, and the BREAKING banner carries it — right.

Authorable-surface artifacts: two rows become … [RETIRED] and no row disappears (the tombstone route keeps the rows) — right; authorable-defaults/system.json drops the two defaults by hand — right, that ratchet does not record a removed default on its own.

ADR-0087 registration, three obligations:

  • D2 email-template-category-is-system-removed: toMajor: 18, retiredFromLoadPath: true, retiredAfter: '17.7.0' (the package label at this head, which the census pin takes for an unpublished entry), stripKeys over mapCollection(stack, 'emailTemplates') — a lossless delete, idempotent by construction, one notice per key, the path naming the template; fixture with 4 notices (the built-in shape, the 17.x-emitted shape, a canonical control) — right. Placement: inserted by identifier between elementTextVariantHeadingLevels and fieldColumnListsCanonicalized, defined directly above the latter, order: 62 is one more than the previous highest (61) and unique — right per the list's own header (conversions-major18-merge.test.ts holds both rules; green).
  • RETIRED_KEYS_BY_MAJOR[18]: system/EmailTemplateDefinition:category and :isSystem, one entry file each, named per the README; the pin asserts no other major carries an EmailTemplateDefinition: key — right.
  • D3 email-template-category-is-system-retired: one family entry, surface backtick-free, reason names the D2 id, acceptanceCriteria non-empty; its STEP18_RATIONALE fragment carries the same id, sits where that id sorts, order: 94 — shared with one other in-flight fragment after the merge, which the header permits — right.
  • Changeset marker adr-0087: registered naming exactly the D2 and D3 ids, one marker — right (Check Changeset success twice on this head).

The seams the safety claim leans on (the dependence the brief names):

  • Stored rows: applyConversionsToStoredItem forces includeRetired: true (conversions/stored.ts); the pin replays an email_template row carrying both 17.x defaults to a clean row the write door accepts — right.
  • Built artifacts, door 1: 58f5c9099 (fix(metadata-core): a ^18 artifact built during the v18 prerelease boots on a later runtime — the door replays step 18 for a floor of 18.0.0 #23023) is an ancestor of this head (merge-base --is-ancestor, exit 0). It replays every retired, non-flip step-18 entry for a 18.0.0 floor; this entry is a retired, non-flip step-18 entry, so a ^18 artifact built before this PR boots with it replayed. A ^17 artifact is refused at the protocol-18 handshake by design, not this PR's concern — right.
  • The registry copy, door 2: 4267484fe (fix(runtime): AppPlugin registers the forward-converted copy of every collection #23024) is an ancestor and is this PR's merge base; AppPlugin now registers the forward-converted copy of every collection, so the served list and the email loader's execution read see the converted template — right.
  • The pin's own artifact leg: ObjectStackDefinitionSchema refuses the persisted shape before applyConversions(…, { includeRetired: true }) and accepts it after, with a lit control (categry refused on both sides) so the reading is not a tautology — right.
  • Bound, stated honestly in the changeset: the out-of-repo consumer population (cloud included) is NOT MEASURED.

Form and bundles: email-template.form.ts drops the select and the checkbox with a comment in place; the served form was measured with neither field — right. The four metadata-forms.generated.ts bundles lose email_template.fields.category / .isSystem and nothing else — right (check:i18n inside Lint & Repo Gates, green); the catalog count pin 670 → 668 is two labels in three locales — right.

Liveness ledger: both rows STAY dead, re-noted on the house REMOVED template (tombstoned at the schema; stripped by the protocol-18 conversion; the entry stays because retiredKey keeps the key in the walked shape, the rls.priority precedent; what to do instead), _note and the README row updated, the dead count unchanged so state-counts does not move — right (Spec property liveness success). Deleting the rows would have reported UNCLASSIFIED.

Writers inside the radius: the six built-in auth templates (12 lines), the showcase template, the ADR-0054 dogfood fixture, the downstream-contract fixture, two plugin-email test fixtures and three docs examples drop the keys; the typed ones were forced by tsc (the built-ins are typed EmailTemplateDefinition) — right. The tree-scoped absence walk reads five roots (packages, examples, skills, content, scripts), the per-extension set under packages/, the non-code extensions plus examples/*/src/**/*.ts under examples/ — exactly the radius scripts/cross-package-test-inputs.mjs already declares for @objectstack/spec (the #15513 and #20126 pins hold those globs, heldBy witnesses present, turbo.json's @objectstack/spec#test:repo mirrors them), so the playbook's declared-once-per-package rule is met without an edit (check:cross-package-test-inputs green). Its exclusions are structural, each with its reason, not an allowlist file: the pin itself, packages/spec/src/conversions/ (the fixture must author the keys), packages/spec/liveness/ (the rows classify the shape, and category sits beside the bodyHtml row there), content/docs/releases/, the gitignored packages/spec/json-schema/, and CHANGELOG.md — right. Bounds I name beyond the test's own list: .tsx and the whole conversions/ directory are outside the walk (the typed channel covers the first; the second is spec-internal), as are docs/**, .claude/**, .github/** and the repo-root files. The matcher's anti-vacuity case and the visited-file floors make a silent empty walk fail.

Lint pins: lint-liveness-properties.test.ts pins that a parsed template lints clean and carries neither key, and that the parse refuses an authored one; runtime-gate.inert-type-writes.test.ts returns the email_template door to silent and asserts the door's schema refuses category; the showcase control is silent — right: the advisory left because the parse no longer emits category, which is the card's "advisory gone".

Docs and census: automation/email-templates.mdx (three examples, the category paragraph rewritten), permissions/system-context.mdx (the collision row now says tombstone), scripts/check-system-context-census.mjs (NON_READ_ANCHORS prose; the isSystem anchor still resolves because the tombstone keeps the property name) — right. content/docs/references/** regenerated (check:docs inside TypeScript Type Check, green). No content/docs/releases/ page and no CHANGELOG.md is touched — right.

Scope and class: exactly the two seed keys; no other dead row, not .objectui-sha, not the sys_email_template object's own columns; the 40-path file list has zero hits in .claude/**, skills/**, docs/adr/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md (Governed Surface Queue Guard success); 1,283 changed lines, under 3,000; head repository equals base repository; draft, no auto-merge, not queued — right: a Tier S record is the right instrument and nothing in the class needs a human approval.

Merge-round deltas in the net diff: the type-alias pin resolved to 775 (main's InstallModeSchema +1 stacked with this −1, both history notes kept, the count recomputed from source by the test itself) — right; references/index.mdx and package-api-assembled.mdx regenerated from the merged tree — right; the runtime fixture is judged in ③.

Check-runs on this head as read: 48 runs — 41 success, 7 skipped, 0 failure, 0 in progress (the second Check Changeset, re-triggered by the body edit, finished success). The seven required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate, Build Core, Governed Surface Queue Guard success; Temporal Conformance (live PG + MySQL) skipped by its paths filter (no driver or temporal path in the diff), as are Console Pin Gate, Dogfood Verify CLI, the dogfood shard matrix and the opt-in tarball smoke — by design, not red. pnpm check:platform-checklist (8 findings, identical on main 4267484fe) is not a check-run on this head and names none of this diff's files.

② Semver level

  • .changeset/22876-email-template-category-is-system-retired.md: @objectstack/spec minor with the **BREAKING** banner, the FROM → TO table (every category value, isSystem true and false, the enum import, the type), the one-line fix, both prescriptions quoted, and one adr-0087: registered marker — matches the diff: a refused authorable surface and a removed export are breaking; the house rule during the launch window is minor plus the banner (check-changeset-no-major refuses major), and the banner is what check-adr-0087-registration reads breaking-ness from.
  • @objectstack/plugin-email patch: the six built-ins drop two inert keys and render and send as before — right. @objectstack/platform-objects patch: the generated bundles drop two labels — right.
  • Rightly undeclared: @objectstack/lint and @objectstack/runtime (test files only), @objectstack/dogfood, @objectstack/downstream-contract and @objectstack/example-showcase (private), scripts/**, content/docs/**. No skip-changeset label — right.
  • Clause-②: no (narrowing) on the PR body (line 2), in the claim 6117767270, in the changeset body, and in triage's correction 6118067953 — consistent, and right: the diff widens no accept set and enlarges no public surface (two keys refused, one def removed, two defaults gone, two form inputs gone; the new conversion and registry entries are the retirement's own bookkeeping), and (narrowing) declares the breaking direction the way scripts/pm/clause2-line.mjs defines it. Triage's first yes (narrowing) was wrong and is corrected on the card.

③ Boundary flags

Stage-0 report 6117722169:

  • open_questions[0], the six realtime_subscription rows (A / B / C): the seat bounded the batch to the two seed keys (claim 6117767270, option A) and the diff honours the bound — no other ledger row, no skills/**. That family's verdict is not in this PR and stays the human-floor decision its ledger file records; nothing to escalate from this record. Answered: outside this PR, bound kept.
  • out_of_scope_findings: the objectui fallback skill.triggerPhrases — carried by the companion and landed (objectui#12193 is the pin); the unreachable JobPreview / EmailTemplatePreview reads — carrier none, noted; the future v18 sys_email_template promotion writer copying category / is_system into an item — a real forward hazard for whoever builds that writer, and a loud one: the write door now refuses both keys and convertStoredItem strips them at read; the stale realtime ledger note — carrier the realtime decision; manifest.integrity / view.name dead-with-readers — the ledger owner. All answered as carried; none is this PR's.

Build report 6120111318:

Seat orders 6120180938, 6120481114, 6123939287, 6124146159: the park, the two Blocked-by: lines and the unlock are all discharged on this head — both doors are ancestors, the pin condition holds on the head and on main, the body carries no Blocked-by: line (read back).

Merge-round report 6126177600 and the seat's acceptance 6126229625:

  • Conflicts: the type-alias pin to 775 by the pin's own counter — right; the two generated reference pages regenerated from the merged tree, the deferral discharged — right. The dev's patch-id reading (35 of 40 files byte-identical before and after the merge) is the dev's measurement; what this record judged is the net diff itself.
  • The fixture edit in packages/runtime/src/app-plugin-converted-collections.test.ts (door 2's own test, domain:cli): the fixture authored a 17.x-emitted template (category: 'custom', isSystem: false), this PR's tree-scoped absence walk refused it on c116f7607 (Test Core (1/6) red), and 1406d0ee7 drops the two keys from the literal and corrects the docstring and header, assertions unchanged, 8 of 8. The leg it no longer exercises: that file's email-template legs no longer prove that AppPlugin's converted copy strips these two keys; they still prove list-versus-item parity and the loader's parse over a template with no retired key. Judgment: the edit is right and the loss is accepted with its gap named. The playbook forbids a kit exclusion in an absence pin (an absence assertion is not a stale mention; excluding the hit deletes the guard) and a non-literal spelling would evade the gate, so the only honest options were this edit or the red. The composition stays pinned in two halves: door 2's generic forward conversion by the same file's dashboard legs over a retired step-18 entry, and this entry's strip by this PR's conversion block (the stored seam, the artifact boot door before and after, every value shape, idempotence, retirement from the load path). The end-to-end reading of these two keys through AppPlugin was taken once at c116f7607 (8 of 8 with both keys present) and recorded in the report — a measurement, not a standing pin. Carrier none, as the seat accepted; I concur. Not a FAIL condition: no gate loses coverage of the composition.
  • check:type-check-debt ran although the order said NOT MEASURED — exit 0, harmless, corrected by the seat — accepted.
  • origin/main has moved past the merge base (it read e49aaec73 at this review); the dev reports none of those commits touch this diff's files and mergeable_state reads clean. The queue rebuilds on current main; the regenerated reference pages and the two count pins are the files to re-check if main moves them before the queue — the landing seat's pre-queue step, not a flag on the diff.
  • The PR body handed as a file was applied by the seat: the ## Merge round (head 1406d0ee7) section and the un-parked blockquote are on the body as read.

PR acceptance notes: bound kept — verified on the file list; the stale checklist prose — accepted; the #22874 / #22943 overlap merged in — the diff carries their lines; the out-of-repo population NOT MEASURED — stated in the changeset; the companion merged and the pin at or past it — verified on the head and on main.

The card's "Done when", against the diff and the measurements on the card: authoring refuses category and isSystem with the prescription (pinned at four doors, measured at the Studio door) — met; the ledger rows retire with them (re-noted tombstones, which is what "retire" means on the tombstone route) — met; the advisory on every template gone (the lint pins) — met; Studio saves an email template without either key (200 at the same door, the served form carries neither field) — met.

Escalations: none.

Implemented-by: claude/issue-22876-dead-key-retirement-batch
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS

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/xl tests tooling

Projects

None yet

2 participants