Skip to content

feat(plugin-email,plugin-auth)!: close the sys_email_template organization door; retire the provenance stamp and the auth SMS seed (ADR-0131 C4, stage 1) - #22087

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15205-templates-resolve-registry
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15205-templates-resolve-registry

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15205
Clause-②: no (narrowing)

Stage 1 of ADR-0131 card C4 (#15205), under the maintainer's ruling C on ADR-0131 §6 Q1 (decision card #22005): email templates are not overridden per organization. What stops being accepted: an organization's create and update of a sys_email_template row are refused with 403 PERMISSION_DENIED, and the message names the closed door. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio email_template save into its sending row, and later the v18 migration ceremony's promotion (#15211). The template provenance stamp retires, and with it three published exports of @objectstack/plugin-email: bindEmailTemplateProvenanceStamp, unbindEmailTemplateProvenanceStamp and EMAIL_TEMPLATE_PROVENANCE_PACKAGE. A boot with phone sign-in on no longer seeds the built-in auth SMS texts into sys_notification_template. What renders unchanged: every email template (the loader still reads sys_email_template, and a Studio edit still reaches the mail at once and survives a restart); every built-in auth SMS text, byte for byte, at every recipient locale; and any sys_notification_template row an operator already has, which still wins.

This PR carries a Tier H ADR edit (a dated note under ADR-0131 §6 Q1), so it needs the maintainer's approval to merge.

The loader half of the card (stage 2: the registry loader, seedTemplates, the boot sweep bootstrapEffectiveEmailTemplates / EffectiveEmailTemplateSources, the live projector) waits on #15206 (C5). Under single, a Studio save of an email_template lands organization-scoped today, and no registry read that omits the organization sees it, live or after a cold boot (measured at e67ba80049; the stop report is on #15205). References: #15194 (the ADR-0131 execution tree), #21785 and #22062 (the current shape of the boot sweep, unchanged here).

What changes

  • plugin-email: the door. New module src/email-template-door.ts (not exported) registers beforeInsert / beforeUpdate hooks on sys_email_template, at priority 10. A write is refused when its hook session names a caller and is not system-elevated. A write with no execution context at all (no caller) passes, the same scope ADR-0123 D2 draws. Delete is not part of this door. EmailServicePlugin binds the door where it bound the stamp, and unbinds it in destroy().
  • plugin-email: the stamp retires. src/email-template-provenance.ts, its per-row test and its three exports are deleted. With the door closed, no non-system update reaches the engine's write, so nothing marks a row customized any more. Rows already marked keep their mark, and the boot seeders still skip them.
  • plugin-auth: the SMS seed retires. seedPhoneSmsTemplates and its kernel:ready call are gone. resolvePhoneSmsTemplateBody walks the locale chain one rung at a time. At each rung it uses an active, non-blank row at that locale; otherwise the built-in text at that locale, when no row exists there at all; otherwise it moves to the next rung. The built-in walk is the floor, as it is on a failed read. That is exactly what the seeded store rendered: the seed inserted the built-in text wherever no row existed, and a deactivated or blank row passed its rung on. Nothing in plugin-auth's package entry changes.
  • ADR-0131: the dated ruling-C note under §6 Q1, spelled as ruled. It names bootstrapEffectiveEmailTemplates / EffectiveEmailTemplateSources. The ADR text is otherwise unchanged.
  • Docs: content/docs/permissions/system-context.mdx row 58 now anchors the door (email-template-door.ts#isOrganizationWrite) in place of the retired stamp. content/docs/automation/email-templates.mdx says a template is changed in Studio and the rows are closed to organization writes.
  • scripts/audits/14744-before-update-per-row-value-probe.mjs: this audit script imported the retired module. Its plugin-email subject and that import are dropped, which is all it needed. The probe still runs (probe: 7 subjects). The dated audit record docs/audits/2026-09-multi-update-per-row-value-census.md stays as measured.
  • platform-objects (cross-lane, its own commit bffc546129): the sys_email_template field help for is_system ("tenants may edit") and customized ("set when an admin edits") became false with this change. Both are corrected in en, zh-CN, ja-JP and es-ES. node scripts/check-i18n-bundles.mjs --write regenerated the en bundle. See Acceptance notes.
  • Changeset: @objectstack/plugin-email and @objectstack/plugin-auth take minor with the BREAKING banner, and @objectstack/platform-objects takes patch. The ADR-0087 disposition is not-required (no-migration-prescription), and each retired export is named.

Pins, each negative one ablated and restored by blob

The SMS legs were re-run at head a4ba9d5070 and the door legs at 1c2a3ad64b (unit) and a93579f453 (dogfood, through dist); neither the door's source nor its tests have changed since. Every leg used scripts/ablation-replace.mjs, which proved that the mutation landed on disk and that the restore matched the HEAD blob with an empty git diff HEAD. Every driver carries an EXIT/INT/TERM trap that restores from HEAD.

Pin Where Ablation Observed
An organization's create, update and predicate update are refused (PERMISSION_DENIED, 403, door named); nothing reaches the driver plugin-email/src/email-template-door.test.ts Refusal line removed 4 failed, 3 passed: the three refusals and the no-customized case went red; the system, no-caller and unbind cases stayed green
The same door over HTTP (PATCH and POST /api/v1/data/sys_email_template as the org admin) dogfood email-template-overlay-survives-boot.dogfood.test.ts, case 3 Refusal line removed, plugin-email rebuilt, ablation-dist-preflight found the marker in 2 dist files Case 3 red (PATCH answered 200), cases 1 and 2 green; after restore and rebuild, --absent passed, the tree was clean, and all 3 were green
No update marks a row customized email-template-door.test.ts §3 (a) The retired stamp restored beside the closed door GREEN: the stamp is unreachable once the door is closed (7/7)
(b) Stamp restored and door refusal removed RED (1 failed): the pin can fail
A fresh boot with phone sign-in on writes no sys_notification_template row plugin-auth/src/phone-sms-seed-retired.test.ts The retired seed restored at kernel:ready RED: 4 rows inserted. The first attempt stayed green because the harness did not answer the data service AuthManager writes through; fixed in a93579f453, then re-ablated
Built-in SMS texts render byte for byte as the seeded store did: every built-in text and locale, plus 4^6 operator stores x 2 topics x 7 locales same file Per-rung built-in clause removed RED (1 failed)
An existing row still wins same file Rows ignored RED (1 failed)

Not ablated, positive pins: a single Studio save still reaches the mail and survives a cold boot. That is dogfood cases 1 and 2, unchanged and green.

Tests

Every result below is at head a4ba9d5070. Each exit code was captured before any pipe.

  • Gates: 123 of 123 exited 0. That is the 62 the dispatch derived plus the 61 more node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives on this change. Reconciled with --ran (exit codes recorded): 123 derived, 123 run, 0 NOT-MEASURED, 0 UNRUN.
  • Fixed along the way (the earlier reds were real):
    • check-tenant-audit-census (and its self-test): the retired seed's write site leaves the census at 232. node scripts/tenant-audit-census.mjs --write regenerated the census page and the counts file, and the page's hand-written figures follow it.
    • check:engine-double-contract and check:where-matcher: the new SMS test doubles now conform. The ledger scripts/engine-double-contract.pinned.json was updated by node scripts/check-engine-double-contract.mjs --write.
  • Re-run after building the packages they read, now green: check:dual-build-cjs-loads and spec check:skill-examples first answered PREREQUISITE NOT MET (exit 3).
  • Packages:
    • @objectstack/plugin-email test: 535 passed. typecheck: OK.
    • @objectstack/plugin-auth test: 2616 passed, 10 skipped. typecheck: OK.
    • @objectstack/platform-objects test: 1006 passed. typecheck: OK.
    • The dependency closure was built first (turbo build --filter=@objectstack/dogfood^..., 63/63).
  • Dogfood: email-template-overlay-survives-boot.dogfood.test.ts, email-template-materialization.dogfood.test.ts and email-template-boot-sweep.test.ts passed 8 of 8. These are the dogfood files that boot plugin-email; none sends SMS. examples/app-showcase/test/email-template-locale.test.ts passed 6 of 6.
  • Narrowed lint, three parts:
    • Population: the 20 changed source files, linted with npx eslint --no-inline-config --format json. None was reported as ignored by the config.
    • Result: 20 files, 0 errors, 0 warnings.
    • Why the narrowing excludes nothing: eslint.config.mjs enables no type-aware linting and no import-resolution rule, so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

维护者速读(草稿)

  • 改了什么:组织不能再直接新建或修改邮件模板记录(sys_email_template),数据接口会返回 403 并说明这扇门已关;平台自己的写入(内置模板、声明模板、Studio 保存后的同步)照常。随之退役"已自定义"标记钩子及其三个导出。手机短信验证码/邀请的内置文案不再在启动时写成 sys_notification_template 行,而是按语言逐级取:有运营自建的行就用行,没有就用内置文案,发出去的短信逐字节不变。ADR-0131 §6 Q1 下补一条日期注记,记录你 10 月 6 日的裁决 C。
  • 为什么改:落实裁决 C 与「我宁可先不让他编辑」——邮件模板不按组织覆盖。先关门,能让 v18 升级仪式要迁移的"已自定义"模板不再继续增加;模板改为从注册表读取的那一半要等 C5(feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206),否则单租户下 Studio 改模板会失效(已实测)。
  • 风险与代价(含回滚):若有脚本或集成通过数据接口写邮件模板记录,会开始收到 403;改模板请走 Studio。代码里直接调用那三个已退役导出的地方会编译失败,删掉调用即可。回滚:revert 本 PR 即恢复旧行为,不涉及任何数据迁移或删除。
  • 席位意见:
  • 你要做的:审阅 ADR-0131 的日期注记(Tier H),同意则批准合并;另请确认 platform-objects 那一处字段说明的跨车道修正可以随本 PR 一起落地。

Acceptance notes

  • The dogfood overlay file is not unchanged. The order asked that email-template-overlay-survives-boot.dogfood.test.ts stay green and unchanged. Its first two cases (the single Studio edit path) are byte-unchanged and green. Its third case, "a data-door edit is stamped customized and survives the next cold boot", pinned exactly the behaviour this PR retires. Measured unchanged on this branch, it went red with the new refusal (expected 403 to be 200). Per the original order's rule ("a dogfood file that pins the retired behaviour is updated in this PR"), case 3 now pins the closed door: an edit and a create are refused with 403 PERMISSION_DENIED and the door named, nothing is marked customized, and the metadata-door wording survives the next cold boot.
  • Cross-lane edit, declared: the platform-objects help-text fix sits in domain:engine's package, outside the claim's file surface. It is made because this change made the two texts false. It lives in one separate commit (bffc546129), so the seat can declare it or drop it.
  • Left for stage 2, by the order's fence: the module docblock of bootstrap-declared-email-templates.ts still names the retired stamp in a @link. That is a comment only, but that file is outside stage 1. packages/spec/liveness/email_template.json's _note narrates the stamp; it is narrative, not an evidence anchor, check:liveness does not read it, and packages/spec is untouched here.
  • Door scope: a write that names no caller (an engine call with no execution context) is not refused. It is not an organization's write, and every known in-repo writer of the table passes the system context anyway.

Generated by Claude Code

claude added 9 commits October 7, 2026 13:15
…retire the provenance stamp

An organization's create and update of a sys_email_template row are refused
with PERMISSION_DENIED / 403 naming the closed door (ADR-0131 D6, ruling C on
section 6 Q1). System-context writes pass: the seeds, the boot sweep, the live
projector of a Studio save. The provenance stamp, which marked such an edit
customized, retires with its exports.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…render per locale rung

A fresh boot with phone sign-in on writes no sys_notification_template row.
Each locale rung renders an operator's active row, or the built-in text where
no row exists at that locale: byte for byte what the seeded store rendered.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…rganization door

Cases 1 and 2 (a Studio save reaches the mail and survives a cold boot) are
unchanged. Case 3 pinned the retired behaviour, a data-door edit stamped
customized; it now pins the refusal of an edit and a create, and that the
metadata-door wording survives the next cold boot.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…door on the docs pages

The dated note names ruling C (decision card 22005, record 6020178017) and the
two symbols the C4 retirement list gains; the ADR text is otherwise unchanged.
The isSystem census row 58 anchors the closed sys_email_template door, the
email-templates page says where a template is edited now, and the 14744
audit probe drops its retired plugin-email subject.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…rganizations may edit a row

The organization door on sys_email_template is closed (ADR-0131 D6, ruling C),
so the is_system help's 'tenants may edit' and the customized help's 'set when
an admin edits' became false. Both say what holds now, in the four bundled
locales; the en bundle is regenerated by check-i18n-bundles --write.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…form-objects patch

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…gine

The ablation that put the seed back stayed green: AuthManager reads its engine
from the 'data' service, which the harness did not answer, so the restored
seed had no engine to write through. The harness now answers both.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…write site retired

node scripts/tenant-audit-census.mjs --write regenerated the census tables
(232 write call sites, one fewer: the retired seedPhoneSmsTemplates insert),
and the page's hand-written figures follow the census.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…where-matcher contracts

The boot harness routes findOne and update through ObjectQL's own dispatch
predicates (ledger recorded by check-engine-double-contract --write), and both
row stores refuse a where combinator instead of reading it as a field name.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-email, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-email/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

42 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 7ef50a4fbbf9c819a550b2943d2ac74a7bd39770.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-email/src/index.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 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 — 20 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 7ef50a4fbbf9c819a550b2943d2ac74a7bd39770 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7ef50a4fbbf9c819a550b2943d2ac74a7bd39770

⚠️ 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 7ef50a4fbbf9c819a550b2943d2ac74a7bd39770 → 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

维护者速读(终稿)· PR #22087(C4 第一段,#15205)

domain:services 2 号席(#21118)· session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T15:28Z。本 PR 含 ADR 改动(Tier H),只能由你批准合并。


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8c5aa50 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15205-templates-resolve-registry branch October 8, 2026 04:58
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 size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants