From 924da8d8ac778356fa97a7c4c76523d2e2f32042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:18:00 +0000 Subject: [PATCH 01/18] feat(plugin-email): a registry email-template loader, pinned byte-identical to the seeded rows createRegistryEmailTemplateLoader resolves email_template metadata by (name, locale) through the protocol's layered execution list (environment over code), falling back to the ObjectQL registry on a host with no metadata door. The locale rules are the row loader's, unchanged; within one slot any other package's item wins over the built-in package's. builtin-auth-render-identity.test.ts renders all 24 built-in auth templates through EmailService.renderTemplate over both the seeded-row path and the registry path and pins both against digests captured from the row path: identical. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/builtin-auth-render-identity.test.ts | 224 ++++++++++++++++++ .../plugin-email/src/template-loader.ts | 161 +++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts diff --git a/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts new file mode 100644 index 00000000000..90e0c80aeff --- /dev/null +++ b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every built-in auth mail renders BYTE-IDENTICAL from the registry to what the + * seeded `sys_email_template` row rendered (ADR-0131 D6/D10, ruling C on §6 Q1: + * "byte-identical to today's seeded row" is read against the template as the + * registry serves it). + * + * The built-ins used to be copied into rows at every boot (`seedTemplates`) and + * resolved from there. They are now code-declared `email_template` items under + * the email plugin's own package and resolved from the registry, so this pins + * the rendered subject, HTML and plain-text faces of every built-in template in + * every locale against `RENDERED` below — captured from the row path, through + * the real `EmailService.renderTemplate`, before that path was deleted. + * + * The subject is pinned in clear; the two bodies by SHA-256, since each is a few + * kilobytes of inline-styled markup. A digest still pins every byte. + */ + +import { createHash } from 'node:crypto'; +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { EmailService, type TemplateLoader } from './email-service.js'; +import { + BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, + createRegistryEmailTemplateLoader, + createSysEmailTemplateLoader, +} from './template-loader.js'; +import { mapTemplateToRow } from './bootstrap-declared-email-templates.js'; +import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; + +/** Every hole any built-in names, with values that exercise escaping (`&`, `<`). */ +const DATA = { + appName: 'Acme & ', + user: { name: 'Ada ', email: 'ada@example.test' }, + resetUrl: 'https://example.test/reset?token=a1&next=/home', + verificationUrl: 'https://example.test/verify?token=v1&next=/home', + magicLinkUrl: 'https://example.test/magic?token=m1&next=/home', + acceptUrl: 'https://example.test/accept?invite=i1&next=/home', + expiresInMinutes: 30, + otp: '424242', + newEmail: 'ada+new@example.test', + supportUrl: 'https://example.test/support?topic=email&lang=x', + role: 'admin', + inviter: { name: 'Grace ', email: 'grace@example.test' }, + organization: { name: 'Analytical & Engines' }, +}; + +/** + * The row path's render of every built-in, captured through this file's own + * `renderAll` before the row path was deleted: subject in clear, HTML and text + * faces by SHA-256. + */ +const RENDERED: Record = { + 'auth.password_reset@en-US': { + subject: 'Reset your Acme & password', + html: '1393acb01d4e1b6c63c7d5515ab54d917df824ad7dd1a79e38b2bb8a8cd7f0a2', + text: 'ec97c967505e9160dac896c7c6bc3ada3cabf16ee910f723b8788b9adb391c26', + }, + 'auth.password_reset@zh-CN': { + subject: '重置您的 Acme & 密码', + html: 'adc34b5840cfd0424fe911014caf6a1ebbfe33a25b88f1c9f5e79d602578b36d', + text: '85b84c74c87e504dcb789431f4be041b80d42068c59ece97ac38c970fac27bcd', + }, + 'auth.password_reset@ja-JP': { + subject: 'Acme & のパスワードを再設定してください', + html: '3b2279570b55fe5f3ca8f29737d9283014b52b0085cf0c2bfa898f0b9432a2b3', + text: '90ac51ed4af2fc147f5f34d4e2c38b970022fc931eac473c029770d7d29c22a7', + }, + 'auth.password_reset@es-ES': { + subject: 'Restablece tu contraseña de Acme & ', + html: '6d037485fc10a5dd96ff347d2e963eb16e56f9b405a2b7a4cd17c92cdff046d8', + text: 'f876214c50d2c5af9a3927ca9b92373389264c1d225675ac556652bc35d09eab', + }, + 'auth.verify_email@en-US': { + subject: 'Verify your Acme & email address', + html: '6762966601763f062f4d94fd4ce92fefc5e70f3c633dfb36192e0b49fe6435da', + text: 'c092293bd22ebe603584b17313fe69630ef7e02afd902c9afcfccf3926606615', + }, + 'auth.verify_email@zh-CN': { + subject: '验证您的 Acme & 邮箱地址', + html: '0ebe7296b2561b3e668f22224a956980ed7544d1471f09fe2c0c2275500681af', + text: '3f671f4982e6c2b6f921a4bc3bebd628f43f01f9eef9442c237673938b6c9cbf', + }, + 'auth.verify_email@ja-JP': { + subject: 'Acme & のメールアドレスを確認してください', + html: '2e67b8f21d18a649b423bdd5de14fa5ed03c3c07a14e3d630da4b589f4855c1c', + text: 'cd091abab8124673ab214481086c0ca5d1cd06c876fa1c015b68e5f75f9e7326', + }, + 'auth.verify_email@es-ES': { + subject: 'Verifica tu dirección de correo de Acme & ', + html: '663f7eeff9a5f300e7603b5f83301565b1c09e278525e351dd4cd89ddcd01b85', + text: '13ec3fae02bbf5ec0a984705d290f5a1c86d5516736cb82a42887f5be235be11', + }, + 'auth.magic_link@en-US': { + subject: 'Your Acme & sign-in link', + html: 'cc0adefd59a0230a2eabe638e7af9fa949f4e565bc57daf1590d05c25d89a50c', + text: '106425fb86676265c6896b5a104a7c63ab61b35b4eee7de38528dc8f25737d79', + }, + 'auth.magic_link@zh-CN': { + subject: '您的 Acme & 登录链接', + html: '9882dfb84ee66942a2dc99c09bf2277e6dc31a2b0c5e2548f83b97be3aeba53f', + text: '3abdea870d8a84989ab009aa1a136a61fed9125741501cdf497df1ca1c20db94', + }, + 'auth.magic_link@ja-JP': { + subject: 'Acme & のサインインリンク', + html: 'bccceb1c4149a70cbb25ad7e3bced6a620ebe9ad7b600a0e771476466ec9dcac', + text: 'b70d1c38d70da880572da6031375a9b0f6a78375d1a125a835c4e063bc1835e4', + }, + 'auth.magic_link@es-ES': { + subject: 'Tu enlace de acceso a Acme & ', + html: '0367cfe001dbc9aedb210b2d14af3de8b623be6d2b0c9ca62910da405ad5678f', + text: '0e8fe995b762d65a08152d727bfd393877783b86bb90d30f0d6a5f1b7ab76dac', + }, + 'auth.invitation@en-US': { + subject: 'Grace invited you to Analytical & Engines', + html: '58aa661450e6d836a02267bede0e644f2f6cfcce2f125e63e4a4cc661d424069', + text: '51133e10b09e812ef7c26e6f3ecef8b4e3af2d631d11b03ab35b868bf3838dc7', + }, + 'auth.invitation@zh-CN': { + subject: 'Grace 邀请您加入 Analytical & Engines', + html: '6789d8a1f8fe77ae9dac42694c4512df93be52edac56bfe91bbfad0a453e63fe', + text: '6594a2e35834be88ba7cab9d1337a74cb6f066adf507afddc72606e21f71d752', + }, + 'auth.invitation@ja-JP': { + subject: 'Grace さんが Analytical & Engines に招待しています', + html: '6f5123d67393d721038b50292106bf83676d946f1950bf45869f16c2e977ef0f', + text: 'bbfd601ea83dab70d6d9bf48da887d7a1890c69efaf3bcc69958c1a4971ac7fe', + }, + 'auth.invitation@es-ES': { + subject: 'Grace te ha invitado a Analytical & Engines', + html: '9f49cf12933faf499b1000b8895d29c4dbfba9b051e695bd845e885b22e7db02', + text: 'f7acc0d60a7fdcb68611e6d69b905a1260b104b9c947e9ae74b595fe25b7330e', + }, + 'auth.two_factor_otp@en-US': { + subject: 'Your Acme & verification code', + html: '3ff1c9565d4c5b9fb98964395062a0f702bb14fb290ccb17e24a0538d3353b5e', + text: 'd767281d14b56a17369784ad05a7883af41cdf798406a74ef76817e1daa16161', + }, + 'auth.two_factor_otp@zh-CN': { + subject: '您的 Acme & 验证码', + html: '7b2efd6150ec4e576edb5583dcb260abf0e6b555295f078d6896d6a940c644a0', + text: '3224e85b7d5075b13d0e610ee847b8bf63be0ad4588e65fc22da0a02f80cd57f', + }, + 'auth.two_factor_otp@ja-JP': { + subject: 'Acme & の確認コード', + html: 'c6cd4f5c44f24498db487162f4dde713f1e3b2c25a19cdf560fb18e88f72073f', + text: 'b659bcff1065e45441afb9f70ae6d026c2fabf8495fca7d5147204344b4101e4', + }, + 'auth.two_factor_otp@es-ES': { + subject: 'Tu código de verificación de Acme & ', + html: 'a6bc07041a34a40a2e4933c77587c97975bb73181643ea8d5c970fb8282560b5', + text: 'd7ad07b890ab2749209cf4e6333c7ad818f9b8a85c0e8f43dbc8050d5cec4293', + }, + 'auth.email_change_notice@en-US': { + subject: 'Security notice: the email address on your Acme & account is being changed', + html: '9e24ed352d228f4f23023a40840fbb23591b5e828298aa107bdd106706f901e5', + text: 'f9971cbe4ee8fe31fac97c7fe9157801fc3523ed33c42d96848e8146c10937eb', + }, + 'auth.email_change_notice@zh-CN': { + subject: '安全通知:您的 Acme & 账号邮箱正在被变更', + html: '5d9d29c6dae4807a025b1442ca6b138b7e94da11c1a7567248ed6c6ff8aa1c4e', + text: '88fa5df276274f93028d3ce5e573c410b7160f86bc934c4dbd3876a2bd07b2f1', + }, + 'auth.email_change_notice@ja-JP': { + subject: 'セキュリティ通知: Acme & アカウントのメールアドレスが変更されようとしています', + html: '92dc4cad3dda75ad9a0bc8b79d8a6263577ed164aa8875429c341896220376a7', + text: 'aab5fa1256173e5c245e90510722a53ef38e1a7ab4cb316926054e47148998d1', + }, + 'auth.email_change_notice@es-ES': { + subject: 'Aviso de seguridad: se está cambiando el correo de tu cuenta de Acme & ', + html: 'cc817fe06bbf628a98b1ad06d34ad51fcfc4749b3ee0d2536cad94632cb6edbb', + text: '13e8e63f4df3367b410fa2ca7a0acdf47eb3b8b060d1e5e96119f17cfbfd03bd', + }, +}; + +const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex'); + +async function renderAll(loader: TemplateLoader): Promise> { + const svc = new EmailService({ transport: { send: async () => ({ messageId: 'x' }) } as never, templateLoader: loader }); + const out: Record = {}; + for (const tpl of BUILTIN_AUTH_TEMPLATES) { + const r = await svc.renderTemplate({ template: tpl.name, locale: tpl.locale, data: DATA }); + out[`${tpl.name}@${tpl.locale}`] = { subject: r.subject, html: sha256(r.html), text: sha256(r.text) }; + } + return out; +} + +/** The seeded-row path: the rows `seedTemplates` wrote, read by the row loader. */ +function rowPathLoader(): TemplateLoader { + const rows = BUILTIN_AUTH_TEMPLATES.map((tpl, i) => ({ id: `etpl_${String(i).padStart(3, '0')}`, ...mapTemplateToRow(tpl) })); + return createSysEmailTemplateLoader({ + async find(_object: string, query: Record) { + const where = (query.where ?? {}) as Record; + const hit = rows.filter((r: Record) => Object.entries(where).every(([k, v]) => r[k] === v)); + return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; + }, + }); +} + +/** The registry path: the built-ins registered through a manifest, as the plugin registers them. */ +function registryPathLoader(): TemplateLoader { + const ql = new ObjectQL(); + ql.registerApp({ + id: BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, + name: 'Email Service', + version: '1.0.0', + type: 'plugin', + scope: 'system', + namespace: 'sys', + emailTemplates: BUILTIN_AUTH_TEMPLATES, + }); + return createRegistryEmailTemplateLoader({ registry: ql.registry as never }); +} + +describe('every built-in auth mail renders byte-identical from the registry', () => { + it('the row path and the registry path render the same bytes', async () => { + const rowPath = await renderAll(rowPathLoader()); + const registryPath = await renderAll(registryPathLoader()); + expect(Object.keys(registryPath)).toHaveLength(BUILTIN_AUTH_TEMPLATES.length); + expect(rowPath).toEqual(RENDERED); + expect(registryPath).toEqual(rowPath); + }); +}); diff --git a/packages/plugins/plugin-email/src/template-loader.ts b/packages/plugins/plugin-email/src/template-loader.ts index 759d2d82e63..e30c6e71af0 100644 --- a/packages/plugins/plugin-email/src/template-loader.ts +++ b/packages/plugins/plugin-email/src/template-loader.ts @@ -57,6 +57,8 @@ import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; +import { EmailTemplateDefinitionSchema, type EmailTemplateDefinitionParsed } from '@objectstack/spec/system'; /** System read context — template resolution is not an end-user query. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -125,3 +127,162 @@ export function createSysEmailTemplateLoader( }, }; } + +// ============================================================================ +// The registry loader (ADR-0131 D6/D10, ruling C on §6 Q1) +// ============================================================================ + +/** + * The package the built-in auth templates are code-declared under: the email + * plugin's own manifest (`EmailServicePlugin.init` registers them through it). + * Module-internal — exported for this package's own wiring and tests only. + */ +export const BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID = 'com.objectstack.service.email'; + +/** The metadata type the loader reads. */ +const EMAIL_TEMPLATE_TYPE = 'email_template'; + +/** + * The reads {@link createRegistryEmailTemplateLoader} resolves through, looked + * up again at every load so a service that registers late is still seen. + */ +export interface EmailTemplateRegistrySources { + /** + * The `protocol` service. `getMetaItemsForExecution` is the layered list + * `GET /meta/email_template` serves — the environment's stored item over + * each code package's declaration, one item per `(package, name, locale)` + * slot — read on its execution face, the one an in-process engine that + * runs the definitions it reads is meant to use. + */ + protocol?: { getMetaItemsForExecution(request: { type: string }): Promise<{ items?: unknown[] }> }; + /** + * The ObjectQL `SchemaRegistry`. Read ONLY when no `protocol` is + * registered: such a host has no metadata door, so no environment item can + * exist and the code declarations the registry holds are already the + * effective set. + */ + registry?: { listItems(type: string): unknown[] }; +} + +/** An item's locale as the bundle key reads it: the schema default when it declares none. */ +function itemLocale(item: Record): string { + return typeof item.locale === 'string' ? item.locale : DEFAULT_TEMPLATE_LOCALE; +} + +/** + * The render input for one resolved definition. Optional keys stay absent + * rather than `null`, so the renderer derives the plain-text part from the + * HTML exactly when the author wrote no `bodyText`. + */ +function toRenderInput(tpl: EmailTemplateDefinitionParsed): EmailTemplateRow { + return { + name: tpl.name, + locale: tpl.locale, + subject: tpl.subject, + body_html: tpl.bodyHtml, + ...(tpl.bodyText ? { body_text: tpl.bodyText } : {}), + ...(tpl.fromOverride?.address ? { + from_address: tpl.fromOverride.address, + ...(tpl.fromOverride.name ? { from_name: tpl.fromOverride.name } : {}), + } : {}), + ...(tpl.replyTo ? { reply_to: tpl.replyTo } : {}), + active: tpl.active, + ...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {}), + }; +} + +/** + * Build the {@link TemplateLoader} `EmailService` resolves templates through: + * `email_template` metadata, read from the registry by `(name, locale)`. + * + * ## What it reads + * + * The layered list the metadata door serves (see + * {@link EmailTemplateRegistrySources.protocol}). Every population of a + * template lives there: an app's declaration (`emailTemplates:`, a + * `*.email-template.ts` file), a Studio save (environment level, ADR-0131 + * D6 — no organization's item is read), and the built-in auth templates, + * which `EmailServicePlugin` code-declares under + * {@link BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID}. Nothing is copied into a + * `sys_email_template` row, so a Studio save is live on the next send with no + * projection and no restart. + * + * Why not `SchemaRegistry.getItem` / `listItems` directly (measured on the + * showcase): `getItem` answers one member of an i18n bundle per name, and + * `listItems` hands back an environment item beside the package entry it + * overrides, both wearing the package's `_packageId` / `_provenance` — so the + * winner cannot be told from the loser there. The layered list has already + * decided it. + * + * ## The resolution rule (unchanged from the row loader it replaces) + * + * 1. **A locale was named** → that exact `(name, locale)`, or `null`. The + * `en-US` fallback stays in `EmailService`'s ladder. + * 2. **No locale** → `(name, 'en-US')`. + * 3. **No locale and no `en-US` member** → the bundle's lowest locale tag. + * + * Within one `(name, locale)`, an item from any other package — or from no + * package — wins over the built-in: an app that declares `auth.password_reset` + * overrides the platform's copy, and an environment item stands in for every + * package slot it overrides. + * + * A failed read propagates. Answering it from the registry would serve the + * package wording over every environment item on a transient storage error; + * the send fails instead, and its caller decides whether to retry. + * + * A malformed item is refused with the schema's own message, never skipped: + * skipping it would fall through to the built-in copy — the mail an author + * believed they had replaced. + */ +export function createRegistryEmailTemplateLoader( + sources: EmailTemplateRegistrySources | (() => EmailTemplateRegistrySources), +): TemplateLoader { + const resolveSources = typeof sources === 'function' ? sources : () => sources; + + const readBundle = async (name: string): Promise[]> => { + const { protocol, registry } = resolveSources() ?? {}; + let items: unknown[]; + if (typeof protocol?.getMetaItemsForExecution === 'function') { + const listed = await protocol.getMetaItemsForExecution({ type: EMAIL_TEMPLATE_TYPE }); + items = (listed?.items ?? []).map(stripReadDecorations); + } else if (typeof registry?.listItems === 'function') { + items = registry.listItems(EMAIL_TEMPLATE_TYPE) ?? []; + } else { + items = []; + } + return items.filter( + (item): item is Record => + !!item && typeof item === 'object' && (item as Record).name === name, + ); + }; + + /** One `(name, locale)` slot: any other package's item before the built-in. */ + const pick = (bundle: Record[], locale: string): Record | undefined => { + const slot = bundle.filter((item) => itemLocale(item) === locale); + return slot.find((item) => item._packageId !== BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID) ?? slot[0]; + }; + + const render = (item: Record | undefined): EmailTemplateRow | null => { + if (!item) return null; + const parsed = EmailTemplateDefinitionSchema.safeParse(item); + if (!parsed.success) { + throw new Error( + `email template '${String(item.name)}' (${itemLocale(item)}) is not a valid email template, so it ` + + `cannot be sent: ${parsed.error.issues.map((i) => i.message).join('; ')}`, + ); + } + return toRenderInput(parsed.data); + }; + + return { + async load(name, locale) { + const bundle = await readBundle(name); + if (locale) return render(pick(bundle, locale)); + const preferred = pick(bundle, DEFAULT_TEMPLATE_LOCALE); + if (preferred) return render(preferred); + // No en-US member in this bundle — rule 3: the lowest locale tag. + const lowest = [...new Set(bundle.map(itemLocale))].sort()[0]; + return lowest === undefined ? null : render(pick(bundle, lowest)); + }, + }; +} From 2759e7927812460e6313f6804b9b469140223729 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:26:51 +0000 Subject: [PATCH 02/18] wip(plugin-email): wire the registry loader; retire the seed, the boot sweep and the live projector Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/auth-email-change-notice.test.ts | 6 +- .../src/auth-templates-locales.test.ts | 6 +- ...bootstrap-declared-email-templates.test.ts | 771 ------------------ .../src/bootstrap-declared-email-templates.ts | 596 -------------- .../src/builtin-auth-render-identity.test.ts | 96 +-- .../email-plugin.attachment-storage.test.ts | 1 - .../src/email-plugin.mail-settings.test.ts | 5 +- .../src/email-plugin.outbox-sweep.test.ts | 2 +- .../src/email-plugin.queue-delivery.test.ts | 2 +- ...mail-plugin.template-runtime-write.test.ts | 504 ++++-------- .../plugins/plugin-email/src/email-plugin.ts | 468 +---------- .../plugins/plugin-email/src/email-service.ts | 23 +- .../src/email-template-door.test.ts | 11 +- .../plugin-email/src/email-template-door.ts | 19 +- packages/plugins/plugin-email/src/index.ts | 13 +- .../plugin-email/src/plain-text-faces.test.ts | 6 +- ...-shutdown-detaches-template-bridge.test.ts | 180 ++-- .../src/sender-address-validation.test.ts | 9 +- .../plugin-email/src/template-loader.test.ts | 287 +++++++ .../plugin-email/src/template-loader.ts | 161 +--- .../src/template-locale-resolution.test.ts | 183 ++--- .../src/templates/auth-templates.ts | 18 +- 22 files changed, 728 insertions(+), 2639 deletions(-) delete mode 100644 packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts delete mode 100644 packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts create mode 100644 packages/plugins/plugin-email/src/template-loader.test.ts diff --git a/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts b/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts index 11c9aa41238..2a798bd6ace 100644 --- a/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts +++ b/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts @@ -11,7 +11,7 @@ * `plugin-auth` owns the SENDING half (that it goes out, to the old address, * without gating — `change-email-delete-user-wiring.test.ts`). This file owns * the AUTHORING half: that the row exists in each locale, that it is - * seeded/resolvable rather than merely exported, and that the prose obeys the + * registered/resolvable rather than merely exported, and that the prose obeys the * ruling's two content constraints. * * Every expectation is written as a literal. Deriving the locale set or the @@ -44,8 +44,8 @@ describe('#8019 — auth.email_change_notice ships in all four locales', () => { expect(new Set(rows.map((t) => t.name))).toEqual(new Set([TEMPLATE_NAME])); }); - it('is seeded with the built-in auth templates, so the send path can resolve it', () => { - // Exported-but-unseeded would pass every other assertion here and still + it('is registered with the built-in auth templates, so the send path can resolve it', () => { + // Exported-but-unregistered would pass every other assertion here and still // throw TEMPLATE_NOT_FOUND on the first real change-email request. const seeded = BUILTIN_AUTH_TEMPLATES.filter((t) => t.name === TEMPLATE_NAME); expect(seeded.map((t) => t.locale).sort()).toEqual([...SUPPORTED_LOCALES].sort()); diff --git a/packages/plugins/plugin-email/src/auth-templates-locales.test.ts b/packages/plugins/plugin-email/src/auth-templates-locales.test.ts index 207ef7bdfcd..f0da66f8f71 100644 --- a/packages/plugins/plugin-email/src/auth-templates-locales.test.ts +++ b/packages/plugins/plugin-email/src/auth-templates-locales.test.ts @@ -45,7 +45,7 @@ const NEWLY_LOCALIZED = [ ] as const; type TemplateName = (typeof NEWLY_LOCALIZED)[number]; -// ── harness: the real EmailService over the real seeded rows ─────────────── +// ── harness: the real EmailService over the real built-in templates ──────── class CaptureTransport implements IEmailTransport { public sent: NormalizedEmailMessage[] = []; @@ -56,7 +56,7 @@ class CaptureTransport implements IEmailTransport { } /** - * A loader over exactly what `EmailServicePlugin` seeds, matching `(name, + * A loader over exactly what `EmailServicePlugin` registers, matching `(name, * locale)` exactly and with no fallback of its own — so the fallback observed * in these tests is the SERVICE's ladder, which is the thing under test. */ @@ -239,7 +239,7 @@ describe('#8195 — the five remaining auth templates ship in all four locales', expect(rows.map((t) => t.locale).sort()).toEqual([...SUPPORTED_LOCALES].sort()); }); - it('every seeded auth row is a valid EmailTemplateDefinition', () => { + it('every registered auth template is a valid EmailTemplateDefinition', () => { for (const t of BUILTIN_AUTH_TEMPLATES) { const parsed = EmailTemplateDefinitionSchema.safeParse(t); expect(parsed.success, `${t.name} @ ${t.locale}: ${parsed.error?.message}`).toBe(true); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts deleted file mode 100644 index 5b96e800cb1..00000000000 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ /dev/null @@ -1,771 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredEmailTemplates — the ingestion bridge that closes #4509 (1). - * - * Verifies that declared `email_template` metadata is materialized into the - * `sys_email_template` rows `sendTemplate` actually reads — keyed by - * `(name, locale)`, idempotently, without clobbering admin edits — and that a - * withdrawn template stops being sent. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { - bootstrapDeclaredEmailTemplates, - bootstrapEffectiveEmailTemplates, - upsertDeclaredEmailTemplate, - deactivateDeclaredEmailTemplate, - mapTemplateToRow, - SWEEP_NAMES_PER_READ, - SWEEP_ROWS_PER_READ, -} from './bootstrap-declared-email-templates.js'; - -// --------------------------------------------------------------------------- -// Fakes — mirrors the ObjectQL surface the bridge touches. -// --------------------------------------------------------------------------- - -interface HookEntry { - event: string; - handler: (ctx: any) => any; - object?: string; - packageId?: string; -} - -class FakeEngine { - rows: Record = {}; - private hooks: HookEntry[] = []; - private declared: Record = {}; - - constructor(seed?: { rows?: Record; declared?: Record }) { - if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); - if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); - } - - // [#8378] Registers items EXACTLY as the real engine does — the document - // itself. This fake used to box each one as `{ content: }`, which made - // it the only producer of that envelope anywhere in the tree: a fiction that - // kept the production `i?.content ?? i` looking load-bearing while nothing - // real ever wrote the shape (`registerMetadataCollections` registers items - // as-is; `loadMetaFromDb` registers the parsed body, not the row). - get _registry() { - return { - listItems: (type: string) => [...(this.declared[type] ?? [])], - }; - } - - private matches(row: any, cond?: Record): boolean { - if (!cond) return true; - // [#22062] `$in` as the real engine reads it: the boot sweep's bulk read. - return Object.entries(cond).every(([k, v]) => - v && typeof v === 'object' && Array.isArray(v.$in) ? v.$in.includes(row[k]) : row[k] === v); - } - - async find(name: string, q?: any): Promise { - const all = this.rows[name] ?? []; - const cond = q?.filter ?? q?.where; - const out = all.filter((r) => this.matches(r, cond)); - // [#22062] Rows leave as COPIES, as a real driver's do. Handing out the - // stored objects let a later write reach into a row the caller had - // already read, which no real read does — and it hid a stale copy in the - // sweep's bulk read from every pin here. - return (typeof q?.limit === 'number' ? out.slice(0, q.limit) : out).map((r) => ({ ...r })); - } - async insert(name: string, data: any): Promise { - const arr = (this.rows[name] = this.rows[name] ?? []); - arr.push({ ...data }); - return data; - } - async update(name: string, data: any, opts?: any): Promise { - const id = data?.id ?? opts?.where?.id; - // [#15302] `previous` is bound BEFORE the beforeUpdate dispatch, as the - // real engine binds it (#5574 / #5846 - by-id reads the prior row ahead of - // the dispatch; each per-row context of a predicate write carries its own). - // Withholding it here is what let this fake model a pre-#5574 engine, and - // a hook reading `ctx.previous` would have gone silently unstamped against - // a fake no production caller resembles. - const cond0 = opts?.where ?? (id ? { id } : undefined); - const previous = (this.rows[name] ?? []).filter((r) => this.matches(r, cond0)).map((r) => ({ ...r }))[0]; - const ctx = { input: { id, data }, previous, session: opts?.context }; - for (const h of this.hooks) { - if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) { - await h.handler(ctx); - } - } - const arr = this.rows[name] ?? []; - const cond = opts?.where ?? (id ? { id } : undefined); - for (const r of arr) { - if (this.matches(r, cond)) Object.assign(r, data); - } - return { affected: 0 }; - } - - registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { - this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); - } - unregisterHooksByPackage(packageId: string): number { - const before = this.hooks.length; - this.hooks = this.hooks.filter((h) => h.packageId !== packageId); - return before - this.hooks.length; - } -} - -const TABLE = 'sys_email_template'; - -function declaredTemplate(over: Record = {}): any { - return { - name: 'auth.password_reset', - label: 'Password Reset', - category: 'auth', - subject: 'Reset your password, {{user.name}}', - bodyHtml: '

Click here

', - variables: [{ name: 'url', type: 'string', required: true }], - ...over, - }; -} - -function rowsOf(engine: FakeEngine): any[] { - return engine.rows[TABLE] ?? []; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('bootstrapDeclaredEmailTemplates', () => { - it('materializes a declared template into a sys_email_template row with the shared mapping', async () => { - const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(rowsOf(engine)).toHaveLength(1); - const row = rowsOf(engine)[0]; - // Spec camelCase → row snake_case, and schema defaults applied. - expect(row.name).toBe('auth.password_reset'); - expect(row.body_html).toBe('

Click here

'); - expect(row.locale).toBe('en-US'); - expect(row.active).toBe(true); - expect(row.is_system).toBe(false); - expect(JSON.parse(row.variables_json)).toEqual([ - { name: 'url', type: 'string', required: true }, - ]); - expect(row.managed_by).toBe('package'); - expect(row.customized).toBe(false); - }); - - it('keys rows by (name, locale) — the same template in two locales is two rows', async () => { - const engine = new FakeEngine({ - declared: { - email_template: [ - declaredTemplate({ locale: 'en-US' }), - declaredTemplate({ locale: 'zh-CN', subject: '重置密码' }), - ], - }, - }); - - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine)).toHaveLength(2); - expect(rowsOf(engine).map((r) => r.locale).sort()).toEqual(['en-US', 'zh-CN']); - }); - - it('is idempotent — re-seeding updates in place rather than duplicating', async () => { - const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); - - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine)).toHaveLength(1); - }); - - it('propagates an edited declaration to a pristine seeded row', async () => { - const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - (engine as any).declared = { - email_template: [declaredTemplate({ subject: 'New subject' })], - }; - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine)).toHaveLength(1); - expect(rowsOf(engine)[0].subject).toBe('New subject'); - }); - - it('never clobbers a row an admin has customized (the seed-not-clobber contract)', async () => { - const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - // A row an organization edited BEFORE the sys_email_template door closed - // (ADR-0131 ruling C): the retired provenance stamp left it marked - // `customized`, and nothing marks a row any more — written here as the - // stored state the v18 migration ceremony will find and promote. - Object.assign(rowsOf(engine)[0], { subject: 'Admin wording', customized: true }); - - (engine as any).declared = { - email_template: [declaredTemplate({ subject: 'Redeploy wording' })], - }; - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(rowsOf(engine)[0].subject).toBe('Admin wording'); - }); - - it('skips an admin-authored row of the same (name, locale) with a warning', async () => { - const engine = new FakeEngine({ - rows: { - [TABLE]: [{ - id: 'etpl_admin', - name: 'auth.password_reset', - locale: 'en-US', - subject: 'Admin original', - managed_by: 'admin', - }], - }, - declared: { email_template: [declaredTemplate()] }, - }); - const warn = vi.fn(); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(rowsOf(engine)[0].subject).toBe('Admin original'); - expect(warn).toHaveBeenCalled(); - }); - - it('adopts a pristine pre-provenance row so future boots recognize it', async () => { - const engine = new FakeEngine({ - rows: { - [TABLE]: [{ - id: 'etpl_legacy', - name: 'auth.password_reset', - locale: 'en-US', - subject: 'Legacy', - }], - }, - declared: { email_template: [declaredTemplate()] }, - }); - - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine)).toHaveLength(1); - expect(rowsOf(engine)[0].managed_by).toBe('package'); - expect(rowsOf(engine)[0].subject).toBe('Reset your password, {{user.name}}'); - }); - - it('skips an invalid declaration without aborting the rest of the batch', async () => { - const engine = new FakeEngine({ - declared: { - email_template: [ - { name: 'broken' }, // no subject / bodyHtml / label - declaredTemplate({ name: 'ops.digest', category: 'notification' }), - ], - }, - }); - const warn = vi.fn(); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); - - expect(result).toEqual({ seeded: 1, skipped: 1 }); - expect(rowsOf(engine).map((r) => r.name)).toEqual(['ops.digest']); - expect(warn).toHaveBeenCalled(); - }); - - it('no-ops when nothing is declared', async () => { - const engine = new FakeEngine(); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 0, skipped: 0 }); - expect(rowsOf(engine)).toHaveLength(0); - }); - - it('falls back to the metadata service when the registry is empty', async () => { - const engine = new FakeEngine(); - // [#8378] `IMetadataService.list` answers the documents themselves; the - // `{ content: … }` box this fixture used to build had no producer. - const metadataService = { list: () => [declaredTemplate()] }; - - const result = await bootstrapDeclaredEmailTemplates(engine as any, metadataService); - - expect(result.seeded).toBe(1); - expect(rowsOf(engine)[0].name).toBe('auth.password_reset'); - }); -}); - -describe('upsertDeclaredEmailTemplate (the live runtime-write path)', () => { - it('materializes a single item — a Studio save takes effect without a restart', async () => { - const engine = new FakeEngine(); - - const written = await upsertDeclaredEmailTemplate( - engine as any, - declaredTemplate({ subject: 'Saved in Studio' }), - ); - - expect(written).toBe(true); - expect(rowsOf(engine)[0].subject).toBe('Saved in Studio'); - }); - - it('rejects a malformed item by throwing, so the caller can warn', async () => { - const engine = new FakeEngine(); - - await expect(upsertDeclaredEmailTemplate(engine as any, { name: 'broken' })) - .rejects.toThrow(); - expect(rowsOf(engine)).toHaveLength(0); - }); -}); - -describe('deactivateDeclaredEmailTemplate (withdrawal)', () => { - it('deactivates every locale of a withdrawn template without deleting rows', async () => { - const engine = new FakeEngine({ - declared: { - email_template: [ - declaredTemplate({ locale: 'en-US' }), - declaredTemplate({ locale: 'zh-CN' }), - ], - }, - }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - const count = await deactivateDeclaredEmailTemplate(engine as any, 'auth.password_reset'); - - expect(count).toBe(2); - expect(rowsOf(engine)).toHaveLength(2); - expect(rowsOf(engine).every((r) => r.active === false)).toBe(true); - }); - - it('leaves admin-authored and customized rows alone', async () => { - const engine = new FakeEngine({ - rows: { - [TABLE]: [ - { id: 'a', name: 'ops.digest', locale: 'en-US', active: true, managed_by: 'admin' }, - { id: 'b', name: 'ops.digest', locale: 'zh-CN', active: true, managed_by: 'package', customized: true }, - ], - }, - }); - - const count = await deactivateDeclaredEmailTemplate(engine as any, 'ops.digest'); - - expect(count).toBe(0); - expect(rowsOf(engine).every((r) => r.active === true)).toBe(true); - }); -}); - -describe('mapTemplateToRow', () => { - it('omits optional columns rather than nulling them, so a re-seed never blanks a field', () => { - const row = mapTemplateToRow({ - name: 'ops.digest', - label: 'Digest', - category: 'notification', - locale: 'en-US', - subject: 'Digest', - bodyHtml: '

hi

', - variables: [], - active: true, - isSystem: false, - } as any); - - expect(row).not.toHaveProperty('body_text'); - expect(row).not.toHaveProperty('reply_to'); - expect(row).not.toHaveProperty('from_address'); - expect(row).not.toHaveProperty('variables_json'); - }); -}); - -// --------------------------------------------------------------------------- -// [#8378] The retired `i?.content ?? i` unwrap -// --------------------------------------------------------------------------- - -/** - * `content` is a spelling an author really can write on an email template — - * `EmailTemplateDefinitionSchema` lists it in its `strictObject` **aliases** - * table (`content: 'bodyHtml'`). That table is a REJECTION facility, not a - * conversion: it feeds `strictUnknownKeyError`, which runs only on the - * `unrecognized_keys` path and only builds a *message*. Nothing rewrites the - * key — the ADR-0087 conversion registry has zero `email_template` entries, so - * `normalizeStackInput` emits no notice and leaves `content` where it was - * written. - * - * So when the key reaches this bridge (a `defineStack(…, { strict: false })` - * load, a hand-built manifest, a direct registry write — every validating door - * refuses it first), the schema is ready with the author's fix. The unwrap was - * the one thing standing between the author and their own prescription: it - * replaced the document with the HTML string, and a string cannot carry a - * key-level rejection. - * - * Both cases below put a `content` key genuinely IN PLAY — the point of the - * fixture. A template that never spells `content` exercises the unwrap's - * `?? i` arm only, and would pass against a completely unfixed tree. - */ -describe('declared email templates carrying the `content` alias spelling (#8378)', () => { - it('reaches the schema as a DOCUMENT, so the rejection carries the `content` → `bodyHtml` fix', async () => { - const engine = new FakeEngine({ - declared: { - email_template: [ - // `bodyHtml` deliberately absent — `content` is the author's attempt - // at it, which is exactly what the alias table exists to answer. - { name: 'auth.welcome', label: 'Welcome', subject: 'Hi', content: '

Welcome

' }, - ], - }, - }); - const warn = vi.fn(); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(rowsOf(engine)).toHaveLength(0); - expect(warn).toHaveBeenCalledTimes(1); - - const [, meta] = warn.mock.calls[0]; - // The unwrap used to hand `.parse()` a bare string, so the diagnostic could - // name neither the offending key nor the template it came from. - expect(meta.name).toBe('auth.welcome'); - expect(meta.error).toContain('content'); - expect(meta.error).toContain('bodyHtml'); - expect(meta.error).not.toContain('expected object, received string'); - }); - - it('does not silently vanish when `content` is the empty string', async () => { - const engine = new FakeEngine({ - declared: { - email_template: [ - // `''` is falsy but NON-nullish, so it passed `??` and was then - // dropped by the reader's own `filter(Boolean)` — the template - // disappeared with no warning, no count and no row. - { name: 'ops.digest', label: 'Digest', subject: 'Daily', content: '' }, - ], - }, - }); - const warn = vi.fn(); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][1].name).toBe('ops.digest'); - }); -}); - -// --------------------------------------------------------------------------- -// [#21785] The boot sweep projects the EFFECTIVE template -// --------------------------------------------------------------------------- - -/** - * A `protocol.getMetaItems` stand-in that answers the way the layered list - * does for ONE org-scoped overlay: read in the overlay's organization, the - * overlay wins its slot; read env-wide (no organization), the declaration is - * served. Each item carries the `_diagnostics` read decoration the real - * served list carries, which the strict schema refuses unless it is stripped. - * Every request is recorded, so the organization the sweep read in is - * asserted rather than assumed. - */ -function layeredProtocol(declared: any[], overlay?: { organizationId?: string; items: any[] }) { - const requests: Array<{ type: string; organizationId?: string }> = []; - return { - requests, - async getMetaItems(request: { type: string; organizationId?: string }) { - requests.push({ ...request }); - // `organizationId` absent on both sides is the environment layer. - const items = overlay && request.organizationId === overlay.organizationId ? overlay.items : declared; - return { type: request.type, items: items.map((i) => ({ ...i, _diagnostics: { valid: true } })) }; - }, - }; -} - -const ORG = 'org_default'; -const PACKAGE_WORDING = 'Reset your password, {{user.name}}'; -const OVERLAY_WORDING = 'Admin reworded: reset for {{user.name}}'; - -/** The row as the live path leaves it after `PUT /meta`: package provenance, overlay wording, NOT customized. */ -function rowProjectedFromOverlay(over: Record = {}): any { - return { - id: 'etpl_seeded', - name: 'auth.password_reset', - locale: 'en-US', - subject: OVERLAY_WORDING, - managed_by: 'package', - customized: false, - ...over, - }; -} - -describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', () => { - it('projects the environment overlay the metadata door serves', async () => { - // The registry holds ONLY the declaration; the door's layered list serves - // the overlay. Reading the registry is the reverted-on-restart defect. - const engine = new FakeEngine({ - rows: { [TABLE]: [rowProjectedFromOverlay()] }, - declared: { email_template: [declaredTemplate()] }, - }); - const protocol = layeredProtocol( - [declaredTemplate()], - { items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, - ); - - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }); - - expect(protocol.requests).toEqual([{ type: 'email_template' }]); - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(rowsOf(engine)).toHaveLength(1); - expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); - }); - - it('[ADR-0131 D6] names no organization: a legacy Default-Organization overlay is not projected', async () => { - // Before the per-organization overlay axis retired, a single-posture - // Studio save landed under the Default Organization and this sweep read - // there. The door no longer serves that row, so neither does the sweep. - const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] } }); - const protocol = layeredProtocol( - [declaredTemplate()], - { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, - ); - - await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }); - - expect(protocol.requests).toEqual([{ type: 'email_template' }]); - expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING); - }); - it('projects nothing on a failed effective read, never the package layer in its place', async () => { - const engine = new FakeEngine({ - rows: { [TABLE]: [rowProjectedFromOverlay()] }, - declared: { email_template: [declaredTemplate()] }, - }); - const warn = vi.fn(); - const protocol = { - async getMetaItems(): Promise { throw new Error('sys_metadata read failed'); }, - }; - - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 0 }); - expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][1]).toEqual({ error: 'sys_metadata read failed' }); - }); - - it('keeps seed-not-clobber over the effective read: admin-authored and customized rows are skipped', async () => { - const engine = new FakeEngine({ - rows: { - [TABLE]: [ - { id: 'a', name: 'ops.digest', locale: 'en-US', subject: 'Admin original', managed_by: 'admin' }, - rowProjectedFromOverlay({ subject: 'Data-door wording', customized: true }), - ], - }, - }); - const warn = vi.fn(); - const protocol = layeredProtocol([], { - items: [ - declaredTemplate({ name: 'ops.digest', category: 'notification', subject: 'Overlay digest' }), - declaredTemplate({ subject: OVERLAY_WORDING }), - ], - }); - - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 2 }); - expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']); - expect(warn).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// [#22062] What a boot costs: a bulk read, and no write for an unchanged row -// --------------------------------------------------------------------------- - -/** - * Before, the sweep looked every template up on its own and rewrote its row - * unconditionally: on a steady boot, one lookup, one UPDATE and the engine's two - * read-backs per template. These pin the three halves of the fix: the bulk read - * (and what happens to a key it does not answer), the compare before the write - * (and the controls that must still be written), and the provenance rules the - * compare must not touch. The row-choice pin over several organizations runs on - * the real engine and SQL driver, in `packages/qa/dogfood` - * (`email-template-boot-sweep.test.ts`): this package cannot import a driver. - */ -describe('[#22062] the boot sweep reads stored rows in bulk and rewrites only what changed', () => { - const isBulkRead = (q: any) => Array.isArray(q?.where?.name?.$in); - - /** One template carrying a string, a boolean, an optional column and `variables_json`. */ - const fullTemplate = () => declaredTemplate({ bodyText: 'Plain body', description: 'Reset mail' }); - - /** Boot once, then change the stored row the way a case says. `before` is the row as the boot left it. */ - async function steadyWith(mutate: (row: any) => void) { - const engine = new FakeEngine({ declared: { email_template: [fullTemplate()] } }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - const before = { ...rowsOf(engine)[0] }; - mutate(rowsOf(engine)[0]); - return { engine, before }; - } - - it('a boot over unchanged templates writes nothing and reads once per 200 names', async () => { - const declared = Array.from({ length: 450 }, (_, i) => declaredTemplate({ name: `tpl.n${i}` })); - const engine = new FakeEngine({ declared: { email_template: declared } }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - const find = vi.spyOn(engine, 'find'); - const update = vi.spyOn(engine, 'update'); - const insert = vi.spyOn(engine, 'insert'); - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 0, skipped: 450 }); - expect(update).not.toHaveBeenCalled(); - expect(insert).not.toHaveBeenCalled(); - // ceil(450 / 200): the bulk reads only. A steady boot has no key the bulk - // read leaves unanswered, so no per-template lookup is added. - expect(SWEEP_NAMES_PER_READ).toBe(200); - expect(find).toHaveBeenCalledTimes(3); - expect(find.mock.calls.every(([, q]) => isBulkRead(q))).toBe(true); - expect(rowsOf(engine)).toHaveLength(450); - }); - - it.each([ - ['a string column (`subject`)', (r: any) => { r.subject = 'Stale wording'; }, 'subject'], - ['a boolean column holding the other boolean (`active`)', (r: any) => { r.active = false; }, 'active'], - ['a boolean column holding the other 0/1 (`active`)', (r: any) => { r.active = 0; }, 'active'], - ['an optional column that is null (`body_text`)', (r: any) => { r.body_text = null; }, 'body_text'], - ['an optional column that is absent (`body_text`)', (r: any) => { delete r.body_text; }, 'body_text'], - ['`variables_json` holding other text', (r: any) => { r.variables_json = '[]'; }, 'variables_json'], - [ - '`variables_json` handed back parsed, and different', - (r: any) => { r.variables_json = [{ name: 'other', type: 'string', required: false }]; }, - 'variables_json', - ], - ] as const)('control: rewrites a package row whose projected column differs: %s', async (_case, mutate, column) => { - const { engine, before } = await steadyWith(mutate); - const update = vi.spyOn(engine, 'update'); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(update).toHaveBeenCalledTimes(1); - expect(rowsOf(engine)[0][column]).toEqual(before[column]); - }); - - it.each([ - ['a boolean stored as 1 (`active: true`)', (r: any) => { r.active = 1; }], - ['a boolean stored as 0 (`is_system: false`)', (r: any) => { r.is_system = 0; }], - ['`variables_json` handed back parsed', (r: any) => { r.variables_json = JSON.parse(r.variables_json); }], - ['a column the projection omits, holding a value (`from_address`)', (r: any) => { r.from_address = 'ops@example.com'; }], - ] as const)('leaves a package row that already holds the projection unwritten: %s', async (_case, mutate) => { - const { engine } = await steadyWith(mutate); - const update = vi.spyOn(engine, 'update'); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(update).not.toHaveBeenCalled(); - }); - - it('still adopts a legacy row with no `managed_by`, even one that already holds the projection', async () => { - const { engine } = await steadyWith((r) => { delete r.managed_by; }); - const update = vi.spyOn(engine, 'update'); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(update).toHaveBeenCalledTimes(1); - expect(rowsOf(engine)[0].managed_by).toBe('package'); - }); - - it('keeps the first row a key meets in the bulk read: the row the per-template lookup returns', async () => { - const stale = (id: string, organization_id: string) => ({ - id, organization_id, name: 'auth.password_reset', locale: 'en-US', subject: `stale ${id}`, managed_by: 'package', - }); - const engine = new FakeEngine({ - rows: { [TABLE]: [stale('etpl_first', 'org_b'), stale('etpl_second', 'org_a')] }, - declared: { email_template: [declaredTemplate()] }, - }); - const [chosen] = await engine.find(TABLE, { where: { name: 'auth.password_reset', locale: 'en-US' }, limit: 1 }); - expect(chosen.id).toBe('etpl_first'); - - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine).map((r) => [r.id, r.subject])).toEqual([ - ['etpl_first', PACKAGE_WORDING], - ['etpl_second', 'stale etpl_second'], - ]); - }); - - it('looks a key the bulk read did not answer up on its own BEFORE inserting anything', async () => { - const engine = new FakeEngine({ declared: { email_template: [declaredTemplate({ name: 'ops.new' })] } }); - const find = vi.spyOn(engine, 'find'); - const insert = vi.spyOn(engine, 'insert'); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(find).toHaveBeenCalledTimes(2); - expect(isBulkRead(find.mock.calls[0][1])).toBe(true); - expect(find.mock.calls[1][1]).toMatchObject({ where: { name: 'ops.new', locale: 'en-US' }, limit: 1 }); - expect(insert).toHaveBeenCalledTimes(1); - expect(find.mock.invocationCallOrder[1]).toBeLessThan(insert.mock.invocationCallOrder[0]); - }); - - it('never inserts a row twice when a bulk read is cut short at its bound', async () => { - // Other locales of the same name fill the bound, stored BEFORE the declared - // slot's own row, so the bulk read stops exactly where that row would come. - const filler = Array.from({ length: SWEEP_ROWS_PER_READ }, (_, i) => ({ - id: `etpl_fill_${i}`, name: 'auth.password_reset', locale: `x-${i}`, subject: 'Other locale', managed_by: 'package', - })); - const engine = new FakeEngine({ rows: { [TABLE]: filler }, declared: { email_template: [declaredTemplate()] } }); - expect(await bootstrapDeclaredEmailTemplates(engine as any, undefined)).toEqual({ seeded: 1, skipped: 0 }); - // ANTI-VACUITY: the bulk read really does not reach the slot's row. - const page = await engine.find(TABLE, { - where: { name: { $in: ['auth.password_reset'] } }, - limit: SWEEP_ROWS_PER_READ, - }); - expect(page.some((r) => r.locale === 'en-US')).toBe(false); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(result).toEqual({ seeded: 0, skipped: 1 }); - expect(rowsOf(engine).filter((r) => r.locale === 'en-US')).toHaveLength(1); - expect(rowsOf(engine)).toHaveLength(SWEEP_ROWS_PER_READ + 1); - }); - - it('looks every template up on its own when the bulk read fails, inserting nothing twice', async () => { - class FailingBulkRead extends FakeEngine { - override async find(name: string, q?: any): Promise { - if (Array.isArray(q?.where?.name?.$in)) throw new Error('bulk read failed'); - return super.find(name, q); - } - } - const declared = [declaredTemplate(), declaredTemplate({ name: 'ops.digest', category: 'notification' })]; - const engine = new FailingBulkRead({ declared: { email_template: declared } }); - const warn = vi.fn(); - expect(await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn })).toEqual({ seeded: 2, skipped: 0 }); - - const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); - - expect(result).toEqual({ seeded: 0, skipped: 2 }); - expect(rowsOf(engine)).toHaveLength(2); - // Said once per boot, never once per template. - expect(warn).toHaveBeenCalledTimes(2); - expect(warn.mock.calls[1][1]).toEqual({ error: 'bulk read failed' }); - }); - - it('meets the row as it now is when the list names one slot twice (a package entry, then its env-wide overlay)', async () => { - const overlay = declaredTemplate({ subject: OVERLAY_WORDING }); - const engine = new FakeEngine({ declared: { email_template: [overlay] } }); - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - // The registry lists an env-wide overlay AFTER the package entry it overrides. - (engine as any).declared = { email_template: [declaredTemplate(), overlay] }; - - await bootstrapDeclaredEmailTemplates(engine as any, undefined); - - expect(rowsOf(engine)).toHaveLength(1); - expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); - }); - - it('the live door shares the write step: an unchanged save writes nothing, a changed one writes', async () => { - const engine = new FakeEngine(); - expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate())).toBe(true); - const update = vi.spyOn(engine, 'update'); - - expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate())).toBe(false); - expect(update).not.toHaveBeenCalled(); - - expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate({ subject: 'Saved again' }))).toBe(true); - expect(update).toHaveBeenCalledTimes(1); - expect(rowsOf(engine)[0].subject).toBe('Saved again'); - }); -}); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts deleted file mode 100644 index 1e3dab0d4ef..00000000000 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ /dev/null @@ -1,596 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredEmailTemplates — materialize declared `email_template` - * metadata into `sys_email_template` rows so `sendTemplate` can actually see - * them (closes #4509 item 1; same disconnect class as webhook #3461). - * - * ## The disconnect this closes - * `EmailService.sendTemplate` resolves templates through a {@link TemplateLoader} - * that reads `sys_email_template` ROWS. Until now the only writers of those rows - * were the built-in auth templates and `EmailServicePluginOptions.templates` — - * a code-only door that no bootstrapper ever passed. Meanwhile the whole - * authoring surface (stack `emailTemplates:`, `*.email-template.ts`, Studio's - * metadata-admin list, `PUT /meta`) decomposed into `email_template` METADATA - * that nothing read back. An admin could "fix" the password-reset mail in - * Studio, get a success toast, and watch users keep receiving the built-in copy - * — ADR-0078 false compliance on authentication email. This seeder is the - * missing ingestion path. - * - * ## Shape translation (authoring → runtime row) - * Unlike webhooks, there is no shape divergence to reconcile: the metadata - * authoring shape and the seeding input are the SAME spec type - * ({@link EmailTemplateDefinition}), so the mapping is exactly the one the - * built-in seeder already uses — {@link mapTemplateToRow}, shared by both doors - * so they can never drift apart. - * - * Each item is validated through `EmailTemplateDefinitionSchema.parse()` first — - * this gives the spec schema a real consumer (defaults for `locale` / `category` - * / `active` / `isSystem` get applied) and rejects malformed authoring with a - * warning instead of crashing boot. - * - * ## Seed-not-clobber (mirrors sys_webhook #3461, sys_sharing_rule #2909) - * `sys_email_template` is admin-editable (`managedBy: 'config'`). Declared - * templates ship with the app/package, so they seed with `managed_by: 'package'` - * provenance and re-seed on every boot — but a row an admin created - * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by - * {@link bindEmailTemplateProvenanceStamp}) is never overwritten. An admin's - * reworded transactional mail survives redeploys. - * - * Note this is a DIFFERENT axis from `is_system`, which the built-in auth - * template seeder uses and which stays exactly as it was. - * - * ## Runtime writes - * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a - * boot-only bridge would leave a Studio save inert until the next restart — the - * same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the - * live `metadata.subscribe('email_template', …)` path in EmailServicePlugin. - * - * ## What the boot sweep projects (#21785) - * The EFFECTIVE template — what the metadata door serves, a Studio overlay - * included — not the package's declaration. The live path already projects - * the overlay when the admin saves it, so a boot sweep reading the package - * layer reverted the sending row on every restart while `GET /meta` kept - * serving the admin's wording. See {@link readDeclared}. - */ - -import type { IDataEngine } from '@objectstack/spec/contracts'; -import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api'; -import { stripReadDecorations } from '@objectstack/spec/kernel'; -import { - EmailTemplateDefinitionSchema, - type EmailTemplateDefinition, -} from '@objectstack/spec/system'; - -/** System write context — the boot seeder is not an admin authoring action. */ -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - -/** Default backing object; overridable for tests. */ -export const EMAIL_TEMPLATE_OBJECT = 'sys_email_template'; - -interface Logger { - info?: (msg: string, meta?: unknown) => void; - warn?: (msg: string, meta?: unknown) => void; -} - -/** - * Translate a validated {@link EmailTemplateDefinition} into - * `sys_email_template` column values. - * - * Shared by BOTH doors — the built-in/options seeder (`EmailServicePlugin`) - * and the declared-metadata bridge — so the authoring shape has exactly one - * runtime projection. Optional keys are omitted rather than nulled so a - * re-seed never blanks a column the author left unset. - */ -export function mapTemplateToRow(tpl: EmailTemplateDefinition): Record { - return { - name: tpl.name, - label: tpl.label, - category: tpl.category, - locale: tpl.locale, - subject: tpl.subject, - body_html: tpl.bodyHtml, - ...(tpl.bodyText ? { body_text: tpl.bodyText } : {}), - ...(tpl.fromOverride?.address ? { - from_address: tpl.fromOverride.address, - ...(tpl.fromOverride.name ? { from_name: tpl.fromOverride.name } : {}), - } : {}), - ...(tpl.replyTo ? { reply_to: tpl.replyTo } : {}), - active: tpl.active, - is_system: tpl.isSystem, - ...(tpl.description ? { description: tpl.description } : {}), - ...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {}), - }; -} - -/** Random id with a stable prefix — mirrors the webhook seeder. */ -function uid(prefix: string): string { - const g: any = globalThis as any; - if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`; - return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; -} - -/** - * The kernel service the boot sweep reads the EFFECTIVE templates through - * (#21785). Optional: a host that registers no `protocol` has no - * metadata door, so nothing can overlay a declaration there and the registry - * read below is already the effective one. - * - * Module-internal: exported for EmailServicePlugin's boot wiring only and - * deliberately NOT re-exported from the package entry — see - * {@link bootstrapEffectiveEmailTemplates}. - */ -export interface EffectiveEmailTemplateSources { - /** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */ - protocol?: { getMetaItems(request: GetMetaItemsRequest): Promise }; -} - -/** {@link readDeclared}'s answer when the effective read did not happen. */ -const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed'); - -/** - * Read the `email_template` items the boot sweep projects: the EFFECTIVE - * items, as the metadata door serves them, when a `protocol` is registered; - * otherwise the declared items from the ObjectQL registry (where the manifest - * decomposition parks `stack.emailTemplates`), falling back to the metadata - * service. Every read hands back the authoring document itself. - * - * ## [#21785] Why the effective read - * - * The registry holds the package's declaration and only the overlays boot - * hydration (`loadMetaFromDb`) registered. The live path projects a Studio - * overlay into the sending row at save time; a sweep that read the package - * layer would write the package wording back on every boot while - * `GET /meta/email_template/:name` kept serving the admin's wording. - * - * So the sweep reads what the door reads — `protocol.getMetaItems`, the - * layered list (environment overlay over package), one item per - * `(name, locale)` slot. - * - * [ADR-0131 D6] It names no organization. The per-organization overlay axis - * is retired: every `/meta` write lands environment-wide, and the door reads - * environment → code. This sweep used to read in the Default Organization - * (`tenancy.defaultOrgId()`), because a `single`-posture Studio save landed - * there; such a legacy row is no longer served by the door, so it is no longer - * projected either, until ADR-0131 C7 promotes it to the environment layer. - * The sending row stays org-agnostic: template resolution keys on - * `(name, locale)` only. - * - * The served items carry read decorations (`_diagnostics`) the strict schema - * refuses, so each is passed through the shared `stripReadDecorations` — the - * same treatment the plugin's single-item effective read applies. - * - * A failed effective read is NOT answered from the registry. The registry - * holds the package wording, so falling back would revert every overlay - * projection on a transient storage error — this defect again, by a second - * route. It answers {@link EFFECTIVE_READ_FAILED} and the sweep projects - * nothing; every row keeps its last projection until the next boot. - * - * ## [#8378] Why there is no `i?.content ?? i` here any more - * - * The sentence this docblock used to end with — "Items may be wrapped as - * `{ content }` — unwrap to the raw authoring object" — described an envelope - * with **no producer**. Re-measured at this seam rather than inherited from - * #7519's measurement of `MetadataFacade`: - * - * - `registerMetadataCollections` (objectql `engine.ts`) registers each - * `stack.emailTemplates` element as-is — `registerItem(type, item, 'name')`, - * no boxing; - * - `loadMetaFromDb` (metadata-protocol) registers - * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never - * the `sys_metadata` row (whose body column is `metadata`, not `content`); - * - `MetadataFacade`'s own interim boxing of non-object values, the one writer - * that ever produced the shape, was removed by #8349. - * - * ## …and why removing it is a FIX rather than a tidy-up - * - * `content` IS a spelling an author can write on an email template — but as a - * **rejection alias**, not a conversion. `EmailTemplateDefinitionSchema`'s - * `strictObject({ aliases: { content: 'bodyHtml', … } })` table feeds - * `strictUnknownKeyError`, which runs only on the `unrecognized_keys` path and - * only builds a *message*; it never rewrites the key. Nor does the ADR-0087 - * conversion layer — `packages/spec/src/conversions/registry.ts` has zero - * `email_template` entries, so `normalizeStackInput` emits no notice and leaves - * `content` exactly where the author wrote it. - * - * So the key survives to this read only through a door that skips validation - * (`defineStack(…, { strict: false })`, a hand-built manifest, a direct - * registry write) — and on precisely that path the unwrap was actively harmful, - * in two ways: - * - * 1. **It destroyed the author's own prescription.** With the unwrap, the - * string reached `EmailTemplateDefinitionSchema.parse()` and the template - * was refused with `Invalid input: expected object, received string`, and - * the boot warning's `name` field came back `undefined` — the operator - * could not even tell which template failed. Without it the document - * reaches the parse intact and the schema answers what it was built to - * answer: *Unrecognized key(s) on this email template: `content`. Did you - * mean `content` → `bodyHtml`?* - * 2. **`content: ''` vanished.** Falsy but non-nullish, so it passed `??` and - * then died at the `filter(Boolean)` below — the template was dropped with - * no warning, no count, nothing (the ADR-0078 silent-loss shape). - */ -async function readDeclared( - engine: any, - metadataService: any, - type: string, - sources: EffectiveEmailTemplateSources | undefined, - logger: Logger | undefined, -): Promise { - const protocol = sources?.protocol; - if (typeof protocol?.getMetaItems === 'function') { - try { - const listed = await protocol.getMetaItems({ type }); - return listed.items.filter(Boolean).map(stripReadDecorations); - } catch (err: any) { - logger?.warn?.( - '[email] effective email-template read failed — no declared template re-materialized this boot; ' - + 'every sys_email_template row keeps its last projection', - { error: err?.message ?? String(err) }, - ); - return EFFECTIVE_READ_FAILED; - } - } - try { - const reg = engine?._registry; - if (reg?.listItems) { - const items = (reg.listItems(type) ?? []).filter(Boolean); - if (items.length > 0) return items; - } - } catch { - /* fall through to metadata service */ - } - try { - const listed = metadataService?.list?.(type); - const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); - return Array.isArray(arr) ? arr.filter(Boolean) : []; - } catch { - return []; - } -} - -export interface BootstrapDeclaredEmailTemplatesResult { - seeded: number; - skipped: number; -} - -/** - * [#22062] How many declared names one bulk read of the boot sweep carries in - * its `$in` list. Module-internal; exported for this package's own tests. - */ -export const SWEEP_NAMES_PER_READ = 200; - -/** - * [#22062] The row bound of one bulk read. Module-internal; exported for this - * package's own tests. - * - * The bound is not a completeness claim. A read that comes back holding this - * many rows may have been cut short, and a key the read did not answer is never - * taken as "no row": the sweep looks that template up on its own before it - * inserts anything (see {@link bootstrapEffectiveEmailTemplates}). Five rows per - * name is room for the usual locale and organization spread, so a steady boot - * pays that fallback only where a name really carries more rows than that. - * - * The bound also does a second job: it makes the read PAGED. A paged read with - * no `orderBy` is the very shape of the per-template lookup - * (`find(…, { where: { name, locale }, limit: 1 })`), so a driver orders both - * the same way, and the first row a key meets in the bulk read is the row that - * lookup returns. Measured on `ObjectQL` over `SqlDriver` (better-sqlite3): both - * statements go out as `… order by id asc limit ?`; the same read with no - * `limit` gets no ORDER BY at all and walks rows in storage order, which chose - * a different row for a `(name, locale)` held by several organizations. - */ -export const SWEEP_ROWS_PER_READ = SWEEP_NAMES_PER_READ * 5; - -/** [#22062] A stored template's key: a template is unique per `(name, locale)`. */ -function templateRowKey(name: unknown, locale: unknown): string { - return JSON.stringify([name, locale]); -} - -/** - * The per-template lookup: the stored row for one `(name, locale)`, or - * `undefined`. The live doors read through it, and so does the boot sweep for - * every key its bulk read did not answer. - */ -async function findTemplateRow( - engine: IDataEngine, - object: string, - name: string, - locale: string | undefined, -): Promise { - const existing = await (engine as any).find(object, { - where: { name, locale }, - limit: 1, - context: SYSTEM_CTX, - }); - return Array.isArray(existing) ? existing[0] : (existing as any)?.data?.[0]; -} - -/** - * [#22062] Read the stored rows for the declared names in bulk, one read per - * {@link SWEEP_NAMES_PER_READ} names, instead of one lookup per template. - * - * Each key keeps the FIRST row it meets, in the read's own order — the order - * the per-template lookup is answered in (see {@link SWEEP_ROWS_PER_READ}). A - * read cut short at its bound keeps that property for every key it answered: - * the rows it returned are a prefix of one order, so a key's first row is in - * it whenever any of its rows is. - * - * Answers `undefined` when a read fails or does not answer a row list. The - * sweep then looks every template up on its own, exactly as it did before this - * read existed — the read is an optimization, and no answer is made up for it. - */ -async function readStoredTemplateRows( - engine: IDataEngine, - declared: unknown[], - object: string, - logger: Logger | undefined, -): Promise | undefined> { - const names = [...new Set(declared.map((item) => (item as { name?: unknown } | null)?.name))] - .filter((name): name is string => typeof name === 'string'); - const byKey = new Map(); - try { - for (let start = 0; start < names.length; start += SWEEP_NAMES_PER_READ) { - const found = await (engine as any).find(object, { - where: { name: { $in: names.slice(start, start + SWEEP_NAMES_PER_READ) } }, - limit: SWEEP_ROWS_PER_READ, - context: SYSTEM_CTX, - }); - if (!Array.isArray(found)) throw new Error('the read answered no row list'); - for (const row of found) { - const key = templateRowKey(row?.name, row?.locale); - if (!byKey.has(key)) byKey.set(key, row); - } - } - } catch (err: any) { - logger?.warn?.( - '[email] bulk read of stored email templates failed — each declared template is looked up on its own instead', - { error: err?.message ?? String(err) }, - ); - return undefined; - } - return byKey; -} - -/** - * [#22062] Does a stored value already hold what the projection would write? - * - * Errs toward "no": a false "yes" keeps a stale template silently, while a - * false "no" costs one write. So a value is held only when it is the projected - * value itself, or one of the two storage spellings a driver may hand back for - * it: `1`/`0` for a boolean column, and — for `variables_json`, a JSON text - * column — the parsed value whose `JSON.stringify` is the projected text - * exactly. Anything else, an absent or `null` column included, is not held. - */ -function storedValueHolds(column: string, stored: unknown, projected: unknown): boolean { - if (stored === projected) return true; - if (typeof projected === 'boolean') return stored === (projected ? 1 : 0); - if (column === 'variables_json' && typeof projected === 'string' && stored !== null && typeof stored === 'object') { - return JSON.stringify(stored) === projected; - } - return false; -} - -/** - * [#22062] True when the row already holds EVERY column the projection writes — - * exactly the column set the update below would write, so "held" means the - * update would change nothing the sender reads. A column the projection omits - * (an optional key the author left unset) is not compared, as the update does - * not write it either. - */ -function rowHoldsProjection(row: Record, projected: Record): boolean { - return Object.entries(projected).every(([column, value]) => storedValueHolds(column, row[column], value)); -} - -/** - * Materialize ONE declared template into `sys_email_template`, honouring - * seed-not-clobber. Shared by the boot sweep and the live subscribe path. - * - * @returns `true` when the row was written, `false` when deliberately not - * written: an admin-owned or customized row, or a package-managed row that - * already holds the template's projection. - * @throws when the raw item fails schema validation, or the write itself fails - * — callers decide whether that warns or propagates. - */ -export async function upsertDeclaredEmailTemplate( - engine: IDataEngine, - raw: unknown, - object = EMAIL_TEMPLATE_OBJECT, - logger?: Logger, -): Promise { - const tpl: EmailTemplateDefinition = EmailTemplateDefinitionSchema.parse(raw); - const row = await findTemplateRow(engine, object, tpl.name, tpl.locale); - return writeTemplateRow(engine, tpl, row, object, logger); -} - -/** - * The one write step behind both doors: write a validated template against the - * row already read for its `(name, locale)` (`undefined` when there is none). - * - * [#22062] A `managed_by: 'package'` row that already holds the projection is - * not rewritten. Before, every boot rewrote every effective template — one - * UPDATE and the engine's read-backs each — whether or not anything changed. - * A row with any other provenance takes exactly the path it took before: an - * admin-owned or customized row is never written, and a legacy row with no - * `managed_by` (or a `platform` one) is rewritten and adopted. - */ -async function writeTemplateRow( - engine: IDataEngine, - tpl: EmailTemplateDefinition, - row: any, - object: string, - logger: Logger | undefined, -): Promise { - const now = new Date().toISOString(); - const projected = mapTemplateToRow(tpl); - - if (row?.id) { - // Admin owns a same-named row, or has edited this seeded one — never - // clobber. A reworded transactional mail must survive redeploys. - if (row.managed_by === 'admin') { - logger?.warn?.('[email] declared template collides with an admin-authored row — seed skipped', { - name: tpl.name, - locale: tpl.locale, - }); - return false; - } - if (row.customized === true) return false; - if (row.managed_by === 'package' && rowHoldsProjection(row, projected)) return false; - await (engine as any).update(object, { - id: row.id, - ...projected, - // Adopt pristine/legacy (pre-provenance) rows so future boots recognize - // them as package-managed. - managed_by: 'package', - updated_at: now, - }, { context: SYSTEM_CTX }); - return true; - } - - await (engine as any).insert(object, { - id: uid('etpl'), - ...projected, - managed_by: 'package', - customized: false, - created_at: now, - updated_at: now, - }, { context: SYSTEM_CTX }); - return true; -} - -/** - * Deactivate the rows a deleted `email_template` metadata item materialized. - * - * Delete events carry only `(type, name)` — no locale — while a template is - * keyed `(name, locale)`, so the sweep is by name across locales. Rows are - * DEACTIVATED rather than deleted: withdrawing an authored template should - * stop it being sent, not destroy a row an admin may have hand-tuned or a - * history an operator may want to read. Admin-authored and `customized` rows - * are left strictly alone. - */ -export async function deactivateDeclaredEmailTemplate( - engine: IDataEngine, - name: string, - object = EMAIL_TEMPLATE_OBJECT, - logger?: Logger, -): Promise { - const found = await (engine as any).find(object, { - where: { name }, - context: SYSTEM_CTX, - }); - const rows: any[] = Array.isArray(found) ? found : ((found as any)?.data ?? []); - let deactivated = 0; - for (const row of rows) { - if (!row?.id) continue; - if (row.managed_by !== 'package') continue; - if (row.customized === true) continue; - if (row.active === false) continue; - await (engine as any).update(object, { - id: row.id, - active: false, - updated_at: new Date().toISOString(), - }, { context: SYSTEM_CTX }); - deactivated += 1; - } - if (deactivated > 0) { - logger?.info?.('[email] declared template withdrawn — rows deactivated', { name, deactivated }); - } - return deactivated; -} - -/** - * Materialize every declared email template into `sys_email_template` from the - * ObjectQL registry (falling back to the metadata service). Idempotent and safe - * to run on every boot. - * - * The published signature, unchanged: an external caller reads the registry - * exactly as before — the declarations plus the env-wide overlays boot - * hydration registered. - */ -export async function bootstrapDeclaredEmailTemplates( - engine: IDataEngine, - metadataService: any, - logger?: Logger, - object = EMAIL_TEMPLATE_OBJECT, -): Promise { - // [#21785] The one sweep body, with no sources. The plugin's own boot wiring - // calls the effective form directly. Said here, not in the docblock above: - // the docblock ships in the published `.d.ts`, and the module-internal name - // is not part of that surface. - return bootstrapEffectiveEmailTemplates(engine, metadataService, undefined, logger, object); -} - -/** - * [#21785] The boot sweep EmailServicePlugin runs: materialize every - * `email_template` into `sys_email_template` as the metadata door serves it — - * an overlay of the declaration included — when `sources.protocol` is given, - * and from the registry otherwise (see {@link readDeclared}). The one sweep - * body; {@link bootstrapDeclaredEmailTemplates} is this with no sources. - * - * Module-internal: NOT re-exported from the package entry (`src/index.ts`), - * and the package's `exports` map names only that entry, so this function and - * {@link EffectiveEmailTemplateSources} add nothing to the published surface. - * No caller outside this package needs them. - * - * ## [#22062] What a boot costs - * - * The stored rows are read in bulk ({@link readStoredTemplateRows}), and a - * package-managed row that already holds its projection is not rewritten (see - * `writeTemplateRow`). Measured on `ObjectQL` over `SqlDriver` (better-sqlite3), - * a steady boot over 450 unchanged templates went from 1,800 statements (a - * lookup, an UPDATE and its two read-backs per template) to 3 reads and no - * write. - * - * A key the bulk read did not answer — a template new since the last boot, a - * read cut short at its bound, or a failed read — is looked up on its own - * BEFORE anything is inserted, so no row the bulk read missed is ever inserted - * twice. A steady boot has no such key and pays nothing for the lookup. - */ -export async function bootstrapEffectiveEmailTemplates( - engine: IDataEngine, - metadataService: any, - sources: EffectiveEmailTemplateSources | undefined, - logger?: Logger, - object = EMAIL_TEMPLATE_OBJECT, -): Promise { - const declared = await readDeclared(engine, metadataService, 'email_template', sources, logger); - if (declared === EFFECTIVE_READ_FAILED || declared.length === 0) return { seeded: 0, skipped: 0 }; - - let seeded = 0; - let skipped = 0; - - const stored = await readStoredTemplateRows(engine, declared, object, logger); - - for (const raw of declared) { - try { - const tpl: EmailTemplateDefinition = EmailTemplateDefinitionSchema.parse(raw); - const key = templateRowKey(tpl.name, tpl.locale); - const row = stored?.has(key) - ? stored.get(key) - : await findTemplateRow(engine, object, tpl.name, tpl.locale); - const written = await writeTemplateRow(engine, tpl, row, object, logger); - // A write leaves the bulk read's copy of this key stale. The list can - // name one slot twice — the registry lists an env-wide overlay after the - // package entry it overrides — and the later item must meet the row as - // it is now, so it is looked up again rather than compared to the copy. - if (written) stored?.delete(key); - if (written) seeded += 1; - else skipped += 1; - } catch (err: any) { - // A malformed template warns and is skipped, never crashing boot — the - // rest of the batch still materializes. - logger?.warn?.('[email] declared email template failed to materialize — skipped', { - name: (raw as any)?.name, - error: err?.message ?? String(err), - }); - skipped += 1; - } - } - - logger?.info?.('[email] declared email templates materialized into sys_email_template', { - seeded, - skipped, - total: declared.length, - }); - return { seeded, skipped }; -} diff --git a/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts index 90e0c80aeff..1776a1b6b8c 100644 --- a/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts +++ b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts @@ -10,8 +10,15 @@ * resolved from there. They are now code-declared `email_template` items under * the email plugin's own package and resolved from the registry, so this pins * the rendered subject, HTML and plain-text faces of every built-in template in - * every locale against `RENDERED` below — captured from the row path, through - * the real `EmailService.renderTemplate`, before that path was deleted. + * every locale against `RENDERED` below — captured from the row path (the rows + * `seedTemplates` wrote through `mapTemplateToRow`, read by the row loader), + * through the real `EmailService.renderTemplate`, in the commit that ran both + * paths side by side and found them identical, before the row path was + * deleted. + * + * The registry path is the real one: `EmailServicePlugin` booted to + * `kernel:ready` over a real ObjectQL, its manifest registered through + * `registerApp`, and the `email` service it installs rendering each template. * * The subject is pinned in clear; the two bodies by SHA-256, since each is a few * kilobytes of inline-styled markup. A digest still pins every byte. @@ -20,13 +27,8 @@ import { createHash } from 'node:crypto'; import { describe, it, expect } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { EmailService, type TemplateLoader } from './email-service.js'; -import { - BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, - createRegistryEmailTemplateLoader, - createSysEmailTemplateLoader, -} from './template-loader.js'; -import { mapTemplateToRow } from './bootstrap-declared-email-templates.js'; +import type { EmailService } from './email-service.js'; +import { EmailServicePlugin } from './email-plugin.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; /** Every hole any built-in names, with values that exercise escaping (`&`, `<`). */ @@ -47,9 +49,8 @@ const DATA = { }; /** - * The row path's render of every built-in, captured through this file's own - * `renderAll` before the row path was deleted: subject in clear, HTML and text - * faces by SHA-256. + * The row path's render of every built-in with `DATA`, captured before the + * row path was deleted: subject in clear, HTML and text faces by SHA-256. */ const RENDERED: Record = { 'auth.password_reset@en-US': { @@ -176,49 +177,40 @@ const RENDERED: Record const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex'); -async function renderAll(loader: TemplateLoader): Promise> { - const svc = new EmailService({ transport: { send: async () => ({ messageId: 'x' }) } as never, templateLoader: loader }); - const out: Record = {}; - for (const tpl of BUILTIN_AUTH_TEMPLATES) { - const r = await svc.renderTemplate({ template: tpl.name, locale: tpl.locale, data: DATA }); - out[`${tpl.name}@${tpl.locale}`] = { subject: r.subject, html: sha256(r.html), text: sha256(r.text) }; - } - return out; -} - -/** The seeded-row path: the rows `seedTemplates` wrote, read by the row loader. */ -function rowPathLoader(): TemplateLoader { - const rows = BUILTIN_AUTH_TEMPLATES.map((tpl, i) => ({ id: `etpl_${String(i).padStart(3, '0')}`, ...mapTemplateToRow(tpl) })); - return createSysEmailTemplateLoader({ - async find(_object: string, query: Record) { - const where = (query.where ?? {}) as Record; - const hit = rows.filter((r: Record) => Object.entries(where).every(([k, v]) => r[k] === v)); - return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; - }, - }); -} - -/** The registry path: the built-ins registered through a manifest, as the plugin registers them. */ -function registryPathLoader(): TemplateLoader { +/** Boot the plugin over a real ObjectQL and hand back the `email` service it installs. */ +async function bootEmailService(): Promise { const ql = new ObjectQL(); - ql.registerApp({ - id: BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, - name: 'Email Service', - version: '1.0.0', - type: 'plugin', - scope: 'system', - namespace: 'sys', - emailTemplates: BUILTIN_AUTH_TEMPLATES, - }); - return createRegistryEmailTemplateLoader({ registry: ql.registry as never }); + const services: Record = { + manifest: { register: (m: unknown) => ql.registerApp(m) }, + objectql: ql, + }; + const hooks: Record Promise | void>> = {}; + const ctx = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`service '${name}' not registered`); + return services[name] as T; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + hook: (name: string, fn: () => Promise | void) => { (hooks[name] ??= []).push(fn); }, + }; + const plugin = new EmailServicePlugin({ persist: false, transport: { send: async () => ({ messageId: 'x' }) } as never }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + return services.email as EmailService; } describe('every built-in auth mail renders byte-identical from the registry', () => { - it('the row path and the registry path render the same bytes', async () => { - const rowPath = await renderAll(rowPathLoader()); - const registryPath = await renderAll(registryPathLoader()); - expect(Object.keys(registryPath)).toHaveLength(BUILTIN_AUTH_TEMPLATES.length); - expect(rowPath).toEqual(RENDERED); - expect(registryPath).toEqual(rowPath); + it('renders every built-in template, in every locale, to the seeded row\'s bytes', async () => { + const svc = await bootEmailService(); + const rendered: Record = {}; + for (const tpl of BUILTIN_AUTH_TEMPLATES) { + const r = await svc.renderTemplate({ template: tpl.name, locale: tpl.locale, data: DATA }); + rendered[`${tpl.name}@${tpl.locale}`] = { subject: r.subject, html: sha256(r.html), text: sha256(r.text) }; + } + + expect(Object.keys(rendered)).toHaveLength(24); + expect(rendered).toEqual(RENDERED); }); }); diff --git a/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts b/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts index 849565cde5e..f802f627da5 100644 --- a/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts @@ -183,7 +183,6 @@ async function boot(opts: BootOpts = {}) { const ctx = fakeCtx(services); const plugin = new EmailServicePlugin({ - seedTemplates: false, transport, queueDelivery: true, defaultFrom: 'no-reply@example.test', diff --git a/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts b/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts index 48b05981a6f..d11515cefef 100644 --- a/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts @@ -93,7 +93,7 @@ async function boot(mailValues: Record, opts: any = {}) { settings, }; const ctx = fakeCtx(services); - const plugin = new EmailServicePlugin({ seedTemplates: false, ...opts }); + const plugin = new EmailServicePlugin({ ...opts }); await plugin.init(ctx as never); await plugin.start(ctx as never); await ctx.fire('kernel:ready'); @@ -329,7 +329,7 @@ describe('applyMailSettings — provider=resend', () => { describe('EmailServicePlugin constructor path (CLI / os serve)', () => { it('THROWS when provider=smtp has no host — a boot that cannot deliver fails loudly', async () => { const ctx = fakeCtx({ manifest: { register: () => {} } }); - const plugin = new EmailServicePlugin({ provider: 'smtp', seedTemplates: false }); + const plugin = new EmailServicePlugin({ provider: 'smtp' }); await expect(plugin.init(ctx as never)).rejects.toThrow(/requires a host/); }); @@ -339,7 +339,6 @@ describe('EmailServicePlugin constructor path (CLI / os serve)', () => { const plugin = new EmailServicePlugin({ provider: 'smtp', providerOptions: { host: 'smtp.aliyun.test', port: 465 }, - seedTemplates: false, }); await plugin.init(ctx as never); expect(transportOf(services.email as EmailService)).toBeInstanceOf(SmtpTransport); diff --git a/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts b/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts index 72eb5c4f1bf..42b848f10e2 100644 --- a/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts @@ -180,7 +180,7 @@ async function boot(opts: BootOpts = {}) { if (opts.queue) services.queue = adapter; const ctx = fakeCtx(services); - const plugin = new EmailServicePlugin({ seedTemplates: false, transport, ...(opts.plugin ?? {}) }); + const plugin = new EmailServicePlugin({ transport, ...(opts.plugin ?? {}) }); await plugin.init(ctx as never); await plugin.start(ctx as never); diff --git a/packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts b/packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts index 4ea21a291db..85dc95cc181 100644 --- a/packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts @@ -182,7 +182,7 @@ async function boot(opts: BootOpts = {}) { if (mode === 'degraded') services.queue = createMemoryQueue(); const ctx = fakeCtx(services); - const plugin = new EmailServicePlugin({ seedTemplates: false, transport, ...(opts.plugin ?? {}) }); + const plugin = new EmailServicePlugin({ transport, ...(opts.plugin ?? {}) }); await plugin.init(ctx as never); await plugin.start(ctx as never); const ready = () => ctx.fire('kernel:ready'); diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index cd20b00d8b8..f01b7a490d0 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -1,34 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #7733 — a runtime `email_template` write must materialize WITHOUT a restart. +// #7733 — a runtime `email_template` write must reach the mail WITHOUT a +// restart. ADR-0131 D6/D10 (ruling C on §6 Q1) changed how. // -// The gap these pin. `bootDeclaredTemplates` wired its live path to -// `metadataService.subscribe('email_template', …)`, whose only producer is -// `MetadataManager.register()` → `notifyWatchers()`. `PUT /api/v1/meta/…` does -// NOT go through there: the REST route calls `protocol.saveMetaItem`, which -// persists to `sys_metadata` and then announces on ITS OWN seam — -// `runMutationProjector` (awaited, ADR-0094) and `onMetadataMutation` -// (fire-and-forget, #2588). `notifyWatchers` has no caller outside -// `metadata-manager.ts`, so the plugin's watcher was registered against an -// event the authoring path never emits: the save returned 200, the row never -// reached `sys_email_template`, and only a restart (which re-runs the boot -// sweep against the persisted row) materialized it. +// The gap #7733 found: the live path listened on `metadataService.subscribe`, +// which `PUT /api/v1/meta/…` never announces on, so a Studio save returned 200 +// and only a restart made it send. The fix then was a projector on the +// protocol's own seam that copied each save into a `sys_email_template` row. // -// So these tests drive the plugin through the seam the PUT path actually -// announces on, and assert the OBSERVABLE effect the QA repro looked for: a -// `sys_email_template` row, right after the write, with no restart. -// -// The protocol double mirrors `ObjectStackProtocolImplementation`'s two -// registration seams and the `MetadataMutationEvent` shape it emits -// (`{ type: , name, state: 'active'|'draft'|'deleted' }`, -// plus `body` on the projector) — the same double every other consumer of this -// seam is tested against (service-i18n's authored-translation sync, -// plugin-security's permission projection). +// Templates no longer live in rows. The plugin's loader reads the protocol's +// layered `email_template` list at every send (`template-loader.ts`), and a +// save is in that list the moment it lands (the protocol's registry +// write-through). So the pins below drive the plugin through that read and +// assert what #7733's QA repro looked for — the saved wording in the very next +// mail — plus what replaced the projection: no seam is armed, and no +// `sys_email_template` row is read or written. import { describe, it, expect, vi } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; import { EmailServicePlugin } from './email-plugin.js'; +import { BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID } from './template-loader.js'; +import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; +import type { EmailService } from './email-service.js'; const TABLE = 'sys_email_template'; @@ -37,115 +30,50 @@ type AnyRecord = Record; // ── doubles ──────────────────────────────────────────────────────────────── /** - * Row store with the slice of ObjectQL the template bridge and the organization - * door touch. `update` routes through `assertEngineUpdateDispatch` so this - * double cannot accept a dispatch shape the real engine would refuse. + * The slice of ObjectQL the plugin touches, recording every call by object, + * so "no `sys_email_template` read or write" is an assertion on calls rather + * than on an empty table. */ function fakeEngine(seed: AnyRecord[] = []) { const rows: AnyRecord[] = seed.map((r) => ({ ...r })); - const matches = (row: AnyRecord, cond?: AnyRecord) => - !cond || - Object.entries(cond).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return row[k] === v; - }); - return { + const calls: Array<{ verb: string; object: string }> = []; + const engine = { rows, - _registry: { listItems: () => [] }, - async find(object: string, q?: AnyRecord) { - if (object !== TABLE) return []; - const out = rows.filter((r) => matches(r, q?.where ?? q?.filter)); - return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; - }, - async insert(_object: string, row: AnyRecord) { - rows.push({ ...row }); - return { id: row.id }; - }, - async update(_object: string, data: AnyRecord, options?: AnyRecord) { - const dispatch = assertEngineUpdateDispatch(data, options); - if (dispatch.kind !== 'by-id') throw new Error(`unexpected update dispatch: ${dispatch.kind}`); - const target = rows.find((r) => r.id === dispatch.id); - if (target) Object.assign(target, data); - return { affected: target ? 1 : 0 }; - }, + calls, + registry: { listItems: (_type: string): AnyRecord[] => [] }, + async find(object: string) { calls.push({ verb: 'find', object }); return []; }, + async findOne(object: string) { calls.push({ verb: 'findOne', object }); return null; }, + async insert(object: string, row: AnyRecord) { calls.push({ verb: 'insert', object }); return { id: row.id }; }, + async update(object: string) { calls.push({ verb: 'update', object }); return { affected: 0 }; }, + async delete(object: string) { calls.push({ verb: 'delete', object }); return { affected: 0 }; }, registerHook() { /* organization door */ }, unregisterHooksByPackage() { return 0; }, }; + return engine; } -/** - * The two seams `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` announce - * on. `save`/`remove` replay what the real protocol does after persistence: - * the awaited per-type projector first (with the just-persisted body), then the - * fire-and-forget listeners. - */ -function fakeProtocol(opts: { projector?: boolean; listeners?: boolean } = {}) { - const withProjector = opts.projector !== false; - const withListeners = opts.listeners !== false; - const projectors = new Map Promise>(); - const listeners: Array<(evt: AnyRecord) => void> = []; - const p: AnyRecord = { - projectorFailures: [] as string[], - async announce(evt: AnyRecord) { - const projector = projectors.get(evt.type); - if (projector) { - // Mirrors `runMutationProjector`: a throw is caught and reported on - // the write's response, never propagated to the caller. - try { await projector(evt); } - catch (e: any) { p.projectorFailures.push(String(e?.message ?? e)); } - } - for (const l of listeners) l(evt); - await new Promise((r) => setTimeout(r, 0)); // let a fire-and-forget path settle - }, - /** A `PUT /api/v1/meta/email_template/:name` that landed. */ - save: (name: string, body: unknown, state: 'active' | 'draft' = 'active') => - p.announce({ type: 'email_template', name, state, body }), - /** A `DELETE /api/v1/meta/email_template/:name` that landed. */ - remove: (name: string) => p.announce({ type: 'email_template', name, state: 'deleted' }), - }; - if (withProjector) { - p.registerMutationProjector = (type: string, fn: (evt: AnyRecord) => Promise) => { - projectors.set(type, fn); - }; - } - if (withListeners) { - p.onMetadataMutation = (fn: (evt: AnyRecord) => void) => { - listeners.push(fn); - return () => { - const i = listeners.indexOf(fn); - if (i >= 0) listeners.splice(i, 1); - }; - }; - } - return p; -} +type FakeEngine = ReturnType; /** - * A metadata service shaped like `MetadataManager`: `subscribe` registers - * cleanly (the boot log the QA run saw), and — the fact under test — the - * runtime-write path NEVER calls it back. + * The protocol as the loader reads it: `getMetaItemsForExecution` answers + * `served` at call time — the layered list, the save already in it. The two + * announcement seams a projector used to arm are spies, so "nothing armed" + * is asserted, not assumed. */ -function fakeMetadataService() { - const watchers: Array<(evt: AnyRecord) => void> = []; - return { - watchers, - list: () => [], - get: async () => undefined, - subscribe: (_type: string, cb: (evt: AnyRecord) => void) => { - watchers.push(cb); - return () => {}; +function fakeProtocol(initial: AnyRecord[] = []) { + const p = { + served: initial, + registerMutationProjector: vi.fn(), + onMetadataMutation: vi.fn(() => () => {}), + async getMetaItemsForExecution(request: { type: string }): Promise<{ type: string; items: AnyRecord[] }> { + if (request.type !== 'email_template') return { type: request.type, items: [] }; + return { type: request.type, items: p.served.map((i) => ({ ...i })) }; }, }; + return p; } -/** Give the protocol double a layered `getMetaItem`, as the real one has. */ -function withEffectiveRead( - protocol: AnyRecord, - read: (name: string) => Promise, -): AnyRecord { - protocol.getMetaItem = async ({ name }: { name: string }) => read(name); - return protocol; -} +type FakeProtocol = ReturnType; function fakeCtx(services: Record) { const hooks: Record Promise | void>> = {}; @@ -164,245 +92,150 @@ function fakeCtx(services: Record) { function template(over: AnyRecord = {}): AnyRecord { return { - name: 'auth.password_reset', - label: 'Password Reset', - category: 'auth', + name: 'ops.digest', + label: 'Digest', + category: 'notification', locale: 'en-US', - subject: 'Reset your password, {{user.name}}', - bodyHtml: '

Click here

', + subject: 'Digest for {{user.name}}', + bodyHtml: '

Hi {{user.name}}

', ...over, }; } /** Boot the plugin to `kernel:ready`, as the kernel does. */ -async function boot(over: { engine?: any; protocol?: any; metadata?: any } = {}) { +async function boot(over: { engine?: FakeEngine; protocol?: FakeProtocol | null } = {}) { const engine = over.engine ?? fakeEngine(); - const protocol = 'protocol' in over ? over.protocol : fakeProtocol(); - const metadata = over.metadata ?? fakeMetadataService(); + const protocol = over.protocol === undefined ? fakeProtocol() : over.protocol; + const metadata = { subscribe: vi.fn(() => () => {}), get: vi.fn(), list: () => [] }; + const manifests: AnyRecord[] = []; + const sent: Array<{ subject?: string }> = []; const services: Record = { - manifest: { register: () => {} }, + manifest: { register: (m: AnyRecord) => { manifests.push(m); } }, objectql: engine, metadata, }; if (protocol) services.protocol = protocol; const ctx = fakeCtx(services); - const plugin = new EmailServicePlugin({ seedTemplates: false }); + const plugin = new EmailServicePlugin({ + persist: false, + transport: { + async send(m: AnyRecord) { sent.push({ subject: m.subject }); return { messageId: `m${sent.length}` }; }, + } as never, + defaultFrom: { address: 'no-reply@example.test' }, + }); await plugin.init(ctx as never); await plugin.start(ctx as never); await ctx.fire('kernel:ready'); - return { plugin, ctx, engine, protocol, metadata }; + const email = services.email as EmailService; + const send = () => email.sendTemplate({ template: 'ops.digest', to: 'ada@example.test', data: { user: { name: 'Ada' } } }); + return { ctx, engine, protocol, metadata, manifests, sent, send }; } -const rowsOf = (engine: any, name: string) => - engine.rows.filter((r: AnyRecord) => r.name === name); +const templateTableCalls = (engine: FakeEngine) => engine.calls.filter((c) => c.object === TABLE); // ── tests ────────────────────────────────────────────────────────────────── -describe('#7733 runtime email_template write materializes without a restart', () => { - it('materializes a PUT /meta save into sys_email_template on the protocol seam', async () => { - const { engine, protocol, ctx } = await boot(); - - await protocol.save('auth.password_reset', template()); - - const rows = rowsOf(engine, 'auth.password_reset'); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ - name: 'auth.password_reset', - // #7731 invariant — the loader queries by `(name, locale)`, so the row's - // `locale` column MUST hold the tag it is queried by. - locale: 'en-US', - subject: 'Reset your password, {{user.name}}', - body_html: '

Click here

', - managed_by: 'package', - }); - expect(protocol.projectorFailures).toEqual([]); - expect(ctx.logger.info).toHaveBeenCalledWith( - expect.stringContaining('materialized from a runtime write'), - ); - }); - - it('re-materializes an edit in place rather than adding a second row', async () => { - const { engine, protocol } = await boot(); +describe('#7733 a runtime email_template write reaches the next mail with no restart — through the registry read', () => { + it('sends a PUT /meta save on the very next send, and the next edit on the send after it', async () => { + const { protocol, sent, send } = await boot(); - await protocol.save('auth.password_reset', template()); - await protocol.save('auth.password_reset', template({ subject: 'Reset it, {{user.name}}' })); + protocol!.served = [template()]; + await send(); + protocol!.served = [template({ subject: 'Reworded digest for {{user.name}}' })]; + await send(); - const rows = rowsOf(engine, 'auth.password_reset'); - expect(rows).toHaveLength(1); - expect(rows[0].subject).toBe('Reset it, {{user.name}}'); + expect(sent.map((m) => m.subject)).toEqual(['Digest for Ada', 'Reworded digest for Ada']); }); - it('deactivates the materialized rows when the item is genuinely withdrawn', async () => { - const { engine, protocol } = await boot(); + it('arms no projector, no mutation listener and no metadata subscription — there is nothing to copy', async () => { + const { protocol, metadata } = await boot(); - await protocol.save('auth.password_reset', template()); - await protocol.remove('auth.password_reset'); - - expect(rowsOf(engine, 'auth.password_reset')[0].active).toBe(false); - }); - - it('re-materializes the packaged baseline a DELETE of an overlay reveals', async () => { - // `DELETE /meta/email_template/:name` discards a CUSTOMIZATION overlay - // (ADR-0005). On an artifact-backed template the packaged declaration is - // still shipping, so the reset must restore it — not retire it. - const protocol = withEffectiveRead(fakeProtocol(), async (name) => - name === 'auth.password_reset' - ? { item: template({ subject: 'The packaged subject' }) } - : undefined); - const { engine } = await boot({ protocol }); - - await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); - await protocol.remove('auth.password_reset'); - - const rows = rowsOf(engine, 'auth.password_reset'); - expect(rows).toHaveLength(1); - expect(rows[0].subject).toBe('The packaged subject'); - expect(rows[0].active).not.toBe(false); + expect(protocol!.registerMutationProjector).not.toHaveBeenCalled(); + expect(protocol!.onMetadataMutation).not.toHaveBeenCalled(); + expect(metadata.subscribe).not.toHaveBeenCalled(); }); - it('restores a baseline served WITH read decorations on it', async () => { - // `getMetaItem` returns a DECORATED item (`_diagnostics` from - // `decorateMetadataItem`, `_packageId` / `_provenance` from the registry - // and the overlay row). `EmailTemplateDefinitionSchema` is a strictObject - // that declares neither `_diagnostics` nor `_draft`, so an unstripped body - // would reject the very baseline the reset exists to restore. - // - // [#16152] It DOES declare the ADR-0010 envelope (`_packageId` / - // `_provenance` and the `_lock*` family, via `MetadataProtectionFields`) — - // those parse clean and are not stripped. See the `#16152` describe block - // below for the pins that hold that apart. - const protocol = withEffectiveRead(fakeProtocol(), async () => ({ - type: 'email_template', - name: 'auth.password_reset', - item: { - ...template({ subject: 'The packaged subject' }), - _diagnostics: { valid: true }, - _packageId: 'com.objectstack.auth', - // [#16152] Was `{ source: 'code' }` — an object, where - // `MetadataProvenanceSchema` is `z.enum(['package','org','env-forced'])`. - // It was never spec-legal; the blanket `_` sweep deleted it before the - // parse could say so, which is the silent swallow this card removes. - // Corrected to the spelling the rest of the repo's fixtures use. - _provenance: 'package', - }, - })); - const { engine } = await boot({ protocol }); - - await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); - await protocol.remove('auth.password_reset'); - - const rows = rowsOf(engine, 'auth.password_reset'); - expect(rows).toHaveLength(1); - expect(rows[0].subject).toBe('The packaged subject'); - expect(rows[0].active).not.toBe(false); + it('reads and writes no sys_email_template row, and leaves the rows already there exactly as they are', async () => { + // A customized row is the v18 migration ceremony's to promote (ruling C); + // this plugin neither promotes nor deletes it — and no longer sends it. + const seeded = [ + { id: 'etpl_c', name: 'ops.digest', locale: 'en-US', subject: 'Data-door wording', customized: true }, + { id: 'etpl_a', name: 'ops.digest', locale: 'zh-CN', subject: 'Admin wording', managed_by: 'admin' }, + ]; + const engine = fakeEngine(seeded); + const { protocol, sent, send } = await boot({ engine }); + + protocol!.served = [template()]; + await send(); + + expect(sent.map((m) => m.subject)).toEqual(['Digest for Ada']); + expect(templateTableCalls(engine)).toEqual([]); + expect(engine.rows).toEqual(seeded); }); - it('deactivates nothing when the effective read fails — a DB blip is not a withdrawal', async () => { - const protocol = withEffectiveRead(fakeProtocol(), async () => { throw new Error('db gone'); }); - const { engine, metadata } = await boot({ protocol }); - // No second source can answer either. - metadata.get = async () => { throw new Error('db gone'); }; + it('a DELETE of an overlay sends the packaged baseline the list then serves', async () => { + const { protocol, sent, send } = await boot(); - await protocol.save('auth.password_reset', template()); - await protocol.remove('auth.password_reset'); + protocol!.served = [template({ subject: 'An operator override' })]; + await send(); + protocol!.served = [template({ subject: 'The packaged subject' })]; + await send(); - expect(rowsOf(engine, 'auth.password_reset')[0].active).not.toBe(false); + expect(sent.map((m) => m.subject)).toEqual(['An operator override', 'The packaged subject']); }); - it('leaves a draft save inert — the staging buffer is not live', async () => { - const { engine, protocol } = await boot(); + it('a withdrawn template stops being sent', async () => { + const { protocol, sent, send } = await boot(); - await protocol.save('auth.password_reset', template(), 'draft'); + protocol!.served = [template()]; + await send(); + protocol!.served = []; - expect(engine.rows).toHaveLength(0); + await expect(send()).rejects.toThrow('TEMPLATE_NOT_FOUND: ops.digest'); + expect(sent).toHaveLength(1); }); - it('ignores mutations of other metadata types', async () => { - const { engine, protocol } = await boot(); + it('a failed read fails the send — it is not a withdrawal, and it does not send the package wording', async () => { + const engine = fakeEngine(); + engine.registry.listItems = () => [template({ subject: 'Package wording' })]; + const { protocol, sent, send } = await boot({ engine }); + protocol!.getMetaItemsForExecution = async () => { throw new Error('db gone'); }; - await protocol.announce({ type: 'view', name: 'v', state: 'active', body: template() }); - - expect(engine.rows).toHaveLength(0); + await expect(send()).rejects.toThrow('db gone'); + expect(sent).toEqual([]); }); - it('never clobbers an admin-authored row of the same name', async () => { - const engine = fakeEngine([{ - id: 'etpl_admin', - name: 'auth.password_reset', - locale: 'en-US', - subject: 'Hand-written by an operator', - managed_by: 'admin', - }]); - const { protocol } = await boot({ engine }); + it('boots on a kernel with no protocol service, sending from the ObjectQL registry', async () => { + const engine = fakeEngine(); + engine.registry.listItems = () => [template()]; + const { ctx, sent, send } = await boot({ engine, protocol: null }); - await protocol.save('auth.password_reset', template()); + await send(); - expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); - expect(rowsOf(engine, 'auth.password_reset')[0].subject).toBe('Hand-written by an operator'); - }); - - it('reports a failed materialization on the write instead of throwing at the author', async () => { - const { protocol, ctx } = await boot(); - - // An item that fails `EmailTemplateDefinitionSchema` — the save persisted, - // the projection cannot. - await protocol.save('auth.broken', { name: 'auth.broken' }); - - expect(protocol.projectorFailures).toHaveLength(1); - expect(ctx.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('runtime email-template sync failed'), - ); - }); - - it('falls back to onMetadataMutation when the protocol has no projector seam', async () => { - const protocol = fakeProtocol({ projector: false }); - const { engine } = await boot({ protocol }); - - await protocol.save('auth.password_reset', template()); - - expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); - }); - - it('materializes exactly once when both seams announce the same write', async () => { - const { engine, protocol } = await boot(); - - await protocol.save('auth.password_reset', template()); - - // Both seams fired (projector + listener); the upsert is keyed on - // `(name, locale)`, so the second one must find and update, not insert. - expect(rowsOf(engine, 'auth.password_reset')).toHaveLength(1); - }); - - it('boots, and keeps the boot sweep, on a kernel with no protocol service', async () => { - const { ctx, engine } = await boot({ protocol: null }); - - expect(engine.rows).toHaveLength(0); + expect(sent.map((m) => m.subject)).toEqual(['Digest for Ada']); expect(ctx.logger.error).not.toHaveBeenCalled(); }); +}); - it('detaches both seams on dispose', async () => { - const { plugin, engine, protocol } = await boot(); - - await plugin.dispose(); - await protocol.save('auth.password_reset', template()); +describe('the built-in auth templates are code-declared items of the plugin\'s own package', () => { + it('registers every built-in, in every locale, through the plugin\'s manifest', async () => { + const { manifests } = await boot(); - expect(engine.rows).toHaveLength(0); + const own = manifests.filter((m) => m.id === BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID); + expect(own).toHaveLength(1); + expect(own[0].emailTemplates).toEqual(BUILTIN_AUTH_TEMPLATES); + expect(own[0].emailTemplates).toHaveLength(24); }); }); // ── #16152 ───────────────────────────────────────────────────────────────── // -// `readEffectiveTemplate` used to strip read decorations with a MODULE-LOCAL -// copy of `stripReadDecorations` that dropped every key starting with `_`. -// The shared list it drifted from (`spec/kernel/metadata-read-decorations.ts`) -// carries exactly `['_diagnostics', '_draft']` and names the ADR-0010 -// protection envelope as "Deliberately NOT" a member — envelope state the -// write path legitimately carries, allowlisted by the closed schemas so a -// served document keeps its provenance on re-parse. -// -// The copy's docblock justified the blanket sweep on the claim that -// `EmailTemplateDefinitionSchema` "declares no underscore key". These pin why -// that claim is false and what the narrow strip must do instead. +// The plugin strips read decorations with the SHARED `stripReadDecorations` +// list (`['_diagnostics', '_draft']`), never a blanket `_` sweep: the ADR-0010 +// protection envelope is declared on `EmailTemplateDefinitionSchema`, and an +// underscore key that is neither declared nor a decoration must stay loud. +// These pins moved with the read: from the delete-reset projection to the +// loader every send goes through. describe('#16152 read-decoration strip uses the shared list, not a blanket `_` sweep', () => { /** The full ADR-0010 envelope, as `MetadataProtectionFields` declares it. */ @@ -417,11 +250,6 @@ describe('#16152 read-decoration strip uses the shared list, not a blanket `_` s } as const; it('the schema declares the ADR-0010 envelope and rejects the read decorations', () => { - // The premise the deleted docblock got backwards, asserted directly - // against the schema rather than inferred from it. `email-template.zod.ts` - // spreads `MetadataProtectionFields` into its `strictObject`, so every - // envelope key is authorable surface here; `_diagnostics` / `_draft` are - // not declared anywhere in that shape, which is why they must be stripped. const withEnvelope = EmailTemplateDefinitionSchema.safeParse({ ...template(), ...ENVELOPE }); expect(withEnvelope.success).toBe(true); // Guarding a KEY's reachability: no `unrecognized_keys` on any envelope key. @@ -442,55 +270,21 @@ describe('#16152 read-decoration strip uses the shared list, not a blanket `_` s } }); - it('re-materializes a baseline served with the FULL protection envelope on it', async () => { - // The envelope rides along on the layered read (`_packageId` / - // `_provenance` from the registry, `_lock*` from the artifact layer). It - // parses clean, so the reset restores the packaged baseline with no - // projector failure — and `mapTemplateToRow`'s closed column list is what - // keeps it out of the row, not a strip. - const protocol = withEffectiveRead(fakeProtocol(), async () => ({ - item: { - ...template({ subject: 'The packaged subject' }), - ...ENVELOPE, - _diagnostics: { valid: true }, - _draft: false, - }, - })); - const { engine } = await boot({ protocol }); - - await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); - await protocol.remove('auth.password_reset'); - - expect(protocol.projectorFailures).toEqual([]); - const rows = rowsOf(engine, 'auth.password_reset'); - expect(rows).toHaveLength(1); - expect(rows[0].subject).toBe('The packaged subject'); - // `sys_email_template` declares no underscore column, and - // `mapTemplateToRow` projects a closed list — so the envelope cannot reach - // the row whatever the strip does. This is the measured reason there is no - // second, envelope-stripping pass beside the shared one. - for (const k of Object.keys(ENVELOPE)) expect(rows[0]).not.toHaveProperty(k); + it('sends a template served with the FULL protection envelope and both read decorations on it', async () => { + const { protocol, sent, send } = await boot(); + + protocol!.served = [{ ...template(), ...ENVELOPE, _diagnostics: { valid: true }, _draft: false }]; + await send(); + + expect(sent.map((m) => m.subject)).toEqual(['Digest for Ada']); }); it('does not silently swallow an underscore key the schema never declared', async () => { - // The blanket sweep dropped EVERY `_` key before the parse, so a key that - // is neither a decoration nor declared — a producer's typo, a decoration - // added upstream and never added to the shared list — vanished and the - // reset reported success. That is precisely the silent-strip failure the - // closed schemas (#4001) exist to end, and the shared list keeps loud: - // the projector surfaces it on the write's own response. - const protocol = withEffectiveRead(fakeProtocol(), async () => ({ - item: { ...template({ subject: 'The packaged subject' }), _notADeclaredKey: 'x' }, - })); - const { engine } = await boot({ protocol }); - - await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); - await protocol.remove('auth.password_reset'); - - expect(protocol.projectorFailures).toHaveLength(1); - expect(protocol.projectorFailures[0]).toContain('_notADeclaredKey'); - // The override row is left exactly as it was — a body the schema refuses - // never becomes a write. - expect(rowsOf(engine, 'auth.password_reset')[0].subject).toBe('An operator override'); + const { protocol, sent, send } = await boot(); + + protocol!.served = [{ ...template(), _notADeclaredKey: 'x' }]; + + await expect(send()).rejects.toThrow('_notADeclaredKey'); + expect(sent).toEqual([]); }); }); diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 402f00a8212..9a82b09e9fe 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -2,11 +2,9 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; -import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { IEmailTransport, EmailAddress, - IMetadataService, IQueueService, QueueBackoffPolicy, } from '@objectstack/spec/contracts'; @@ -20,7 +18,7 @@ import { type EmailQueueDelivery, type TemplateLoader, } from './email-service.js'; -import { createSysEmailTemplateLoader } from './template-loader.js'; +import { BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, createRegistryEmailTemplateLoader } from './template-loader.js'; import { makeTransport, SmtpTransport, @@ -31,17 +29,9 @@ import { } from './transports/index.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; import type { - EmailTemplateDefinition as EmailTemplate, SettingsChangeHandler, SettingsUnsubscribe, } from '@objectstack/spec/system'; -import { - bootstrapEffectiveEmailTemplates, - upsertDeclaredEmailTemplate, - deactivateDeclaredEmailTemplate, - mapTemplateToRow, - type EffectiveEmailTemplateSources, -} from './bootstrap-declared-email-templates.js'; import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js'; import { sweepStrandedOutbox, type OutboxSweepResult } from './outbox-sweep.js'; import { readInternalHeadersJson } from './internal-header-readback.js'; @@ -58,13 +48,6 @@ import { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; -/** - * "No source could answer" — distinct from "nothing declares this name", which - * is a plain `undefined`. Only the latter may retire a materialized template - * row; see {@link EmailServicePlugin.readEffectiveTemplate}. - */ -const FAILED_READ = Symbol('email-template-read-failed'); - /** * Plugin configuration. */ @@ -95,10 +78,6 @@ export interface EmailServicePluginOptions { retries?: number; /** Default template render context (merged into every sendTemplate call). */ defaultTemplateContext?: Record; - /** Seed built-in auth templates into sys_email_template on startup. Default true. */ - seedTemplates?: boolean; - /** Additional templates seeded alongside the built-ins. */ - templates?: EmailTemplate[]; /** * Deliver through the durable `queue` service instead of inline (#5160). * Default false — inline delivery, unchanged. @@ -274,12 +253,13 @@ function assertSendableDefaultFrom(from: EmailAddress | undefined): void { * EmailServicePlugin — registers the `email` service. * * Lifecycle: - * - `init`: register sys_email + sys_email_template via manifest; + * - `init`: register sys_email + sys_email_template and the built-in auth + * templates (code-declared `email_template` items) via manifest; * build transport (config → provider+apiKey → LogTransport fallback); * register a transport-only EmailService so dependents can resolve it. * - `start` (kernel:ready): wire ObjectQL-backed sys_email persistence - * + sys_email_template TemplateLoader; seed built-in auth templates - * (upsert by `(name, locale)`). + * + the registry TemplateLoader, and close the `sys_email_template` + * organization door. */ export class EmailServicePlugin implements Plugin { name = 'com.objectstack.service.email'; @@ -323,23 +303,6 @@ export class EmailServicePlugin implements Plugin { private service?: EmailService; /** Engine carrying the closed `sys_email_template` door — unbound in destroy(). */ private boundEngine?: IDataEngine; - /** Live `email_template` metadata subscription — detached in dispose(). */ - private unsubscribeTemplates?: () => void; - /** - * Live `email_template` protocol-mutation listener (#7733) — detached in - * dispose(). Only set on the `onMetadataMutation` fallback; the preferred - * `registerMutationProjector` seam is a per-type Map slot with no - * unregister verb, so that half is released by dropping the protocol. - */ - private unsubscribeTemplateMutations?: () => void; - /** - * Whether the live template bridge is armed. Cleared in dispose(), and read - * by the materializer because the projector seam has no unregister verb: a - * disposed plugin's projector stays in the protocol's per-type slot, and - * without this it would keep writing `sys_email_template` rows through an - * engine whose organization door has already been unbound. - */ - private templateBridgeArmed = false; /** SMTP transport currently in use, if any — closed in dispose(). */ private liveSmtp?: SmtpTransport; /** @@ -392,9 +355,13 @@ export class EmailServicePlugin implements Plugin { // knowable — before a single message depends on it (#14318). assertSendableDefaultFrom(this.options.defaultFrom); - // Register sys_email + sys_email_template via manifest service. + // Register sys_email + sys_email_template via manifest service, and the + // built-in auth templates as code-declared `email_template` items of this + // package (ADR-0131 D6/D10): the registry loader resolves them like any + // app's declaration, an app declaring the same `(name, locale)` wins over + // them, and a Studio save overrides them at environment level. ctx.getService<{ register(m: any): void }>('manifest').register({ - id: 'com.objectstack.service.email', + id: BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, name: 'Email Service', version: '1.0.0', type: 'plugin', @@ -402,6 +369,7 @@ export class EmailServicePlugin implements Plugin { defaultDatasource: 'cloud', namespace: 'sys', objects: [SysEmail, SysEmailTemplate], + emailTemplates: BUILTIN_AUTH_TEMPLATES, }); const transport = this.resolveTransport(ctx); @@ -652,11 +620,17 @@ export class EmailServicePlugin implements Plugin { }, }; - // Locale resolution lives in its own module (#7731) — the inline version - // here queried `{ name }` unordered with `limit: 1` whenever no locale - // was passed, which answered an i18n bundle with whatever row the driver - // yielded first instead of the documented en-US default. - const templateLoader: TemplateLoader = createSysEmailTemplateLoader(engine as any); + // [ADR-0131 D6/D10] Templates resolve from the registry: the protocol's + // layered `email_template` list (environment over code) when a metadata + // door is mounted, the ObjectQL registry otherwise. Both are looked up + // at each load — resolve where it is used — so a Studio save is live on + // the next send, with no row, no projector and no restart. + const registry = (engine as { registry?: unknown }).registry; + const templateLoader: TemplateLoader = createRegistryEmailTemplateLoader(() => { + let protocol: unknown; + try { protocol = ctx.getService('protocol'); } catch { /* no metadata door on this host */ } + return { protocol, registry } as never; + }); // Mutate the existing service instance so consumers that already // captured a reference (e.g. AuthManager) see the upgrade. @@ -953,31 +927,7 @@ export class EmailServicePlugin implements Plugin { })(); } - // Seed built-in + user-provided templates (upsert by name+locale). - if (this.options.seedTemplates !== false) { - const all = [ - ...BUILTIN_AUTH_TEMPLATES, - ...(this.options.templates ?? []), - ]; - for (const tpl of all) { - try { await this.upsertTemplate(engine!, tpl); } - catch (err: any) { - ctx.logger.warn(`EmailServicePlugin: seed template failed: ${tpl.name} ${tpl.locale}`, err?.message || err); - } - } - ctx.logger.info(`EmailServicePlugin: seeded ${all.length} template row(s)`); - } - - // ── DECLARED email_template METADATA → sys_email_template (#4509) ── - // The second door: everything authored as `email_template` metadata - // (stack `emailTemplates:`, `*.email-template.ts`, Studio, PUT /meta) - // materializes into the rows sendTemplate actually reads. Gated on the - // DATA ENGINE alone — materializing rows is a pure write, so it must not - // sit behind transport/settings availability. (The webhook bridge learned - // this the hard way: gated behind its dispatch prerequisites, a - // realtime-less deployment silently materialized nothing — the very - // no-op class this closes, #3461.) - await this.bootDeclaredTemplates(ctx, engine); + this.bindTemplateDoor(ctx, engine); }); } @@ -1033,329 +983,25 @@ export class EmailServicePlugin implements Plugin { } /** - * [#4509] Materialize declared `email_template` metadata, close the - * organization door on `sys_email_template`, and keep the rows live for - * runtime authoring. - * - * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a - * boot-only sweep would leave a Studio save inert until the next restart — - * the same bug, half-fixed. - * - * [#7733] The live path needs BOTH announcements, because the two authoring - * doors announce on different seams and neither one covers the other: - * - * • `metadataService.subscribe('email_template', …)` — fed by - * `MetadataManager.register()` → `notifyWatchers()`. That is the artifact - * / package-ingest door. `register` announces only AFTER the write has - * landed, so re-reading on the event cannot race the data. - * - * • `protocol.registerMutationProjector` / `onMetadataMutation` — fed by - * `saveMetaItem` / `publishMetaItem` / `deleteMetaItem`, i.e. the - * RUNTIME-AUTHORING door: `PUT /api/v1/meta/email_template/:name`, the - * Studio save behind it, the AI builders, direct protocol callers. - * - * The comment this replaces claimed the first seam covered "Studio saves / - * PUT /meta" too. It does not, and nothing else made up the difference: - * `saveMetaItem` persists to `sys_metadata`, write-throughs to the ObjectQL - * SchemaRegistry (the `[Registry] Registered email_template` line the QA run - * saw) and announces on its OWN seam — it never calls `register()`, and - * `notifyWatchers` has no caller outside `MetadataManager`. So the watcher - * was armed against an event the PUT path never emits: 200 on the write, no - * row in `sys_email_template`, and only a restart — which re-runs the boot - * sweep over the persisted row — made the template send. - * - * The projector seam is preferred over the listener seam for the same reason - * plugin-security's permission projection prefers it (ADR-0094): it is - * AWAITED inside the write, so `PUT` → read `sys_email_template` is - * consistent with no race window, and a materialization failure is reported - * on the write's own response (`projectionApplied`) instead of vanishing into - * a log. `onMetadataMutation` is the fallback for protocol implementations - * that predate the projector. - * - * Both seams landing the same write is harmless and NOT guarded against: - * `upsertDeclaredEmailTemplate` is keyed on `(name, locale)` and idempotent, - * so a second delivery updates the row it just wrote. Deduping would need - * per-write state whose only job is to skip a write that costs one indexed - * lookup. + * Close the organization door on `sys_email_template` (ADR-0131 D6, ruling C + * on §6 Q1): an organization does not create or edit a template row. The + * table keeps the rows earlier releases wrote — customized ones included, + * which the v18 migration ceremony promotes to environment-level templates — + * but nothing in this plugin reads or writes it any more: templates resolve + * from the registry (see the loader wired above). See email-template-door.ts. */ - private async bootDeclaredTemplates(ctx: PluginContext, engine: IDataEngine): Promise { - // ADR-0131 D6, ruling C on §6 Q1: an organization does not create or edit - // a template row. Only system-context writes reach the table — the seeds, - // the boot sweep and the live projector below — see email-template-door.ts. - // This replaced the provenance stamp, which marked an organization's edit - // `customized` instead of refusing it. + private bindTemplateDoor(ctx: PluginContext, engine: IDataEngine): void { this.boundEngine = engine; - this.templateBridgeArmed = true; try { bindEmailTemplateDoor(engine as any, ctx.logger as any); } catch (err: any) { ctx.logger.error( 'EmailServicePlugin: the sys_email_template organization door was NOT closed — an organization\'s ' - + 'create and update of a template row are accepted for the life of this process, while every ' - + 'template it sends still comes from these rows. Cause: ' + (err?.message ?? err), - ); - } - - let metadataService: IMetadataService | undefined; - try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } - - // [#21785] The sweep projects the EFFECTIVE template — what `GET /meta` - // serves, an environment Studio overlay included — through the protocol's - // layered list. Optional: a host without it has no metadata door to - // overlay through. - let protocol: EffectiveEmailTemplateSources['protocol']; - try { protocol = ctx.getService('protocol'); } catch { /* optional */ } - - try { - await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol }, ctx.logger as any); - } catch (err: any) { - ctx.logger.warn( - 'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): ' - + (err?.message ?? err), - ); - } - - // Live door 1 — package ingest / artifact reload, via the metadata service. - if (typeof metadataService?.subscribe === 'function') { - try { - this.unsubscribeTemplates = metadataService.subscribe('email_template', (event: any) => { - void this.materializeTemplateMutation(ctx, engine, { - name: event?.name, - // Watch events name the CHANGE (`added` / `changed` / `deleted` / - // `unlink`); mutation events name the resulting STATE. Fold to the - // one verb the materializer takes. - deleted: event?.type === 'deleted' || event?.type === 'unlink', - body: event?.data, - metadataService, - }).catch(() => { /* already logged */ }); - }); - ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata changes'); - } catch (err: any) { - ctx.logger.warn('EmailServicePlugin: email_template subscription failed: ' + (err?.message ?? err)); - } - } - - // Live door 2 — runtime authoring (`PUT /meta`, Studio, publish, delete), - // via the protocol's post-persistence seam. [#7733] - this.wireTemplateMutationProjection(ctx, engine, metadataService); - } - - /** - * Arm the protocol-side half of the live template path (#7733). - * - * Prefers the AWAITED ADR-0094 projector so the write itself carries the - * materialization; falls back to the fire-and-forget `onMetadataMutation` - * listener (#2588) on protocol implementations that predate it. Silent no-op - * when the kernel registers no protocol at all (a data-plane-only host) — - * that deployment simply has no runtime-authoring door to bridge, and the - * boot sweep above still covers it. - */ - private wireTemplateMutationProjection( - ctx: PluginContext, - engine: IDataEngine, - metadataService: IMetadataService | undefined, - ): void { - let protocol: any; - try { protocol = ctx.getService('protocol'); } catch { return; } - if (!protocol) return; - - // `MetadataMutationEvent.state` is the row's resulting lifecycle. `draft` - // is the ADR-0005 staging buffer and is deliberately NOT live — the same - // reading every other consumer of this seam applies. - const materialize = (evt: any) => { - if (evt?.state === 'draft') return undefined; - return this.materializeTemplateMutation(ctx, engine, { - name: evt?.name, - deleted: evt?.state === 'deleted', - body: evt?.body, - metadataService, - protocol, - }); - }; - - try { - if (typeof protocol.registerMutationProjector === 'function') { - protocol.registerMutationProjector('email_template', async (evt: any) => { - // Throws on purpose: `runMutationProjector` catches it and reports - // `projectionApplied: { success:false, error }` on the save's own - // response, so an author whose template did not materialize learns - // it from the write instead of from a mail that never arrives. - await materialize(evt); - }); - ctx.logger.info('EmailServicePlugin: projecting email_template metadata mutations'); - return; - } - if (typeof protocol.onMetadataMutation === 'function') { - this.unsubscribeTemplateMutations = protocol.onMetadataMutation((evt: any) => { - if (evt?.type !== 'email_template') return; - void materialize(evt)?.catch(() => { /* already logged */ }); - }); - ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata mutations'); - } - } catch (err: any) { - ctx.logger.warn( - 'EmailServicePlugin: email_template mutation projection failed to wire: ' + (err?.message ?? err), + + 'create and update of a template row are accepted for the life of this process, and the rows ' + + 'the v18 migration promotes can be changed under it. Cause: ' + (err?.message ?? err), ); } } - /** - * Apply ONE announced `email_template` change to `sys_email_template` — - * shared by both live doors, so a Studio save and a package ingest land - * through exactly the same write (and the same seed-not-clobber rules). - * - * `body` is used when the announcement carries it (the projector's - * just-persisted item, a watch event's `data`); otherwise the item is - * re-read through the metadata service. Both are re-read AFTER persistence, - * so neither can race the row it describes. - * - * A DELETE is not automatically a withdrawal. `DELETE /meta/:type/:name` - * discards a CUSTOMIZATION overlay (ADR-0005), so on an artifact-backed - * template it resets to the packaged declaration rather than removing it — - * deactivating there would silently retire a template the package still - * ships. So a delete re-reads the EFFECTIVE item (ADR-0094 names this the - * projector's job) and re-materializes the revealed baseline; only when - * nothing resolves is the template genuinely gone and the rows deactivated. - * A FAILED read is not an answer and deactivates nothing — a transient DB - * error must never be what stops a live template being sent. - * - * Rethrows after logging: the projector caller turns that into the write's - * `projectionApplied` verdict, and the fire-and-forget callers swallow it. - */ - private async materializeTemplateMutation( - ctx: PluginContext, - engine: IDataEngine, - evt: { - name: unknown; - deleted: boolean; - body: unknown; - metadataService: IMetadataService | undefined; - protocol?: any; - }, - ): Promise { - if (!this.templateBridgeArmed) return; - try { - if (evt.deleted) { - const revealed = await this.readEffectiveTemplate(ctx, evt); - if (revealed === FAILED_READ) return; - if (revealed) { - await upsertDeclaredEmailTemplate(engine, revealed, undefined, ctx.logger as any); - ctx.logger.info( - `EmailServicePlugin: email template '${evt.name}' reset to its packaged declaration`, - ); - return; - } - // Genuinely withdrawn. Delete announcements carry no locale — - // deactivate by name, and only rows this bridge owns. - await deactivateDeclaredEmailTemplate(engine, String(evt.name ?? ''), undefined, ctx.logger as any); - return; - } - const raw = evt.body ?? (evt.name - ? await evt.metadataService?.get?.('email_template', String(evt.name)) - : undefined); - if (!raw) return; - // [#8378] `evt.body` / `metadataService.get` hand back the authoring - // document itself — there is no `{ name, content }` envelope. Unwrapping - // could only have replaced the template with one of its values, and on - // this door `content` cannot even arrive: `saveMetaItem` validates every - // `email_template` write against `EmailTemplateDefinitionSchema` and - // refuses the key with 422 `INVALID_METADATA` before persisting. - await upsertDeclaredEmailTemplate(engine, raw, undefined, ctx.logger as any); - ctx.logger.info(`EmailServicePlugin: email template '${evt.name}' materialized from a runtime write`); - } catch (err: any) { - ctx.logger.warn( - `EmailServicePlugin: runtime email-template sync failed for '${evt.name}': ${err?.message ?? err}`, - ); - throw err; - } - } - - /** - * The effective (layered) `email_template` body a delete may have revealed, - * `undefined` when nothing declares the name any more, or {@link FAILED_READ} - * when no source could answer. - * - * `protocol.getMetaItem` first — it is the layered read, so it reports the - * packaged declaration an overlay was hiding. `metadataService.get` is the - * fallback for hosts without that surface; it answers from the artifact - * registry, which for a delete is the same baseline. The three outcomes are - * kept apart on purpose: only "nothing declares it" may deactivate a row. - * - * The body is stripped of read decorations before it is returned, using the - * SHARED `stripReadDecorations` from `@objectstack/spec/kernel` — the same - * helper the other read-back-envelope consumers call (the dataset query in - * `rest-server.ts`, the cold-boot flow bind in `service-automation`, - * `saveMetaItem`'s verbatim persist, and the route-level seed apply). This is - * the read-side twin of the strip `saveMetaItem` does on the write side. - * - * ## [#16152] Why this is the shared list and NOT a blanket `_`-sweep - * - * This call used to be a module-local copy that dropped **every** key - * starting with `_`, justified in its own docblock by the claim that - * `EmailTemplateDefinitionSchema` "declares no underscore key". That claim is - * false, and the schema disagrees in both directions: - * - * - `METADATA_READ_DECORATIONS` is `['_diagnostics', '_draft']`, and those - * two genuinely must go: the schema is a `strictObject`, neither key is - * declared, so an unstripped body rejects the very baseline this read - * exists to restore. - * - The ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, - * `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) is - * **declared** — `email-template.zod.ts` spreads `MetadataProtectionFields` - * into the shape — and `metadata-read-decorations.ts` names it - * "Deliberately NOT" a member of the strip list, because it is envelope - * state the write path legitimately carries. Sweeping it here removed keys - * the schema was deliberately widened to accept. - * - * A blanket sweep is also a silent swallow: an underscore key that is neither - * a decoration nor declared is exactly what the closed schema exists to - * reject (#4001), and dropping it before the parse converts a loud - * `unrecognized_keys` into a quiet success. - * - * ⭐ There is deliberately **no** second, envelope-stripping pass beside this - * one. The envelope cannot reach the written row in the first place: - * `upsertDeclaredEmailTemplate` projects the parsed template through - * `mapTemplateToRow`, a closed column list, and `sys_email_template` declares - * no underscore column. Spelling an envelope strip here would encode a rule - * this path does not have, as a second copy of a truth that already lives in - * that projection — which is the drift this fix removes, re-introduced one - * layer up. - */ - private async readEffectiveTemplate( - ctx: PluginContext, - evt: { name: unknown; metadataService: IMetadataService | undefined; protocol?: any }, - ): Promise { - const name = String(evt.name ?? ''); - if (!name) return undefined; - let answered = false; - let item: unknown; - if (typeof evt.protocol?.getMetaItem === 'function') { - try { - const res: any = await evt.protocol.getMetaItem({ type: 'email_template', name }); - answered = true; - item = res?.item ?? res?.data ?? (res?.name ? res : undefined); - } catch (err: any) { - ctx.logger.warn( - `EmailServicePlugin: effective read of email template '${name}' failed: ${err?.message ?? err}`, - ); - } - } - if (!answered && typeof evt.metadataService?.get === 'function') { - try { - item = await evt.metadataService.get('email_template', name); - answered = true; - } catch (err: any) { - ctx.logger.warn( - `EmailServicePlugin: effective read of email template '${name}' failed: ${err?.message ?? err}`, - ); - } - } - if (!answered) return FAILED_READ; - if (!item) return undefined; - // [#8378] The effective read answers the template document itself; no - // `{ name, content }` envelope exists to unwrap on this path either. - return stripReadDecorations(item); - } - /** * Teardown — the kernel's ONLY teardown hook. * @@ -1373,11 +1019,6 @@ export class EmailServicePlugin implements Plugin { * teardown is a no-op rather than a second close. */ async destroy(): Promise { - this.templateBridgeArmed = false; - try { this.unsubscribeTemplates?.(); } catch { /* best effort */ } - this.unsubscribeTemplates = undefined; - try { this.unsubscribeTemplateMutations?.(); } catch { /* best effort */ } - this.unsubscribeTemplateMutations = undefined; if (this.liveSmtp) { try { await this.liveSmtp.close(); } catch { /* best effort */ } this.liveSmtp = undefined; @@ -1588,49 +1229,6 @@ export class EmailServicePlugin implements Plugin { ); } } - - /** - * Seed a built-in / options-supplied template. Provenance axis here is - * `is_system` — see {@link bootstrapDeclaredEmailTemplates} for the DECLARED - * metadata door, which uses `managed_by`/`customized` and shares this exact - * row mapping via {@link mapTemplateToRow}. - * - * New rows are stamped `managed_by: 'platform'` — NOT left to the column's - * `'admin'` default. The default exists for rows an admin creates through the - * data door, and the declared-metadata bridge refuses to overwrite `admin` - * rows; an unstamped built-in would therefore masquerade as admin-authored - * and permanently outrank a template the app actually declared. That is the - * #4509 failure exactly (an authored password-reset mail losing to the - * built-in copy), and the ADR-0054 proof pins it. - */ - private async upsertTemplate(engine: IDataEngine, tpl: EmailTemplate): Promise { - const row = mapTemplateToRow(tpl); - const existing = await (engine as any).find('sys_email_template', { - where: { name: tpl.name, locale: tpl.locale }, - limit: 1, - context: SYSTEM_CTX, - }); - const existingRow = Array.isArray(existing) ? existing[0] : (existing as any)?.data?.[0]; - if (existingRow?.id) { - // Only re-seed if the existing row is system-managed (is_system=true); - // never overwrite a tenant-customised row. - if (existingRow.is_system === false) return; - // A row the declared bridge already owns keeps its `package` provenance — - // re-stamping it `platform` would hand the built-in door a veto it does - // not have. - await (engine as any).update('sys_email_template', { id: existingRow.id, ...row }, { - context: SYSTEM_CTX, - }); - } else { - await (engine as any).insert('sys_email_template', { - ...row, - managed_by: 'platform', - customized: false, - }, { - context: SYSTEM_CTX, - }); - } - } } function extractOverrides(payload: unknown): Record { diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 42fd31f756a..8a51efc8419 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -463,25 +463,28 @@ function newId(): string { export const DEFAULT_TEMPLATE_LOCALE = 'en-US'; /** - * Loader for sys_email_template rows. Injected by EmailServicePlugin - * on `kernel:ready`. Returns the row `(name, locale)` resolves to under - * the exact rule below — there is no "best match" and no language-subtag - * folding — or `null` when none exists / inactive. + * Template loader. EmailServicePlugin injects the registry loader + * (`createRegistryEmailTemplateLoader`) on `kernel:ready`. Returns the + * template `(name, locale)` resolves to under the exact rule below — there is + * no "best match" and no language-subtag folding — or `null` when none exists. + * An inactive template is returned; the service refuses it (`TEMPLATE_INACTIVE`). * * `locale` set → an EXACT match for that locale, or `null`; the en-US fallback * lives in {@link EmailService.sendTemplate}'s ladder rather than in the * loader, so a replacement loader cannot relocate the documented default. * `locale` undefined → the loader's own deterministic best answer for the name - * (see `createSysEmailTemplateLoader`), which `sendTemplate` consults only as a - * last resort, when the bundle carries no en-US row at all. + * (see `createRegistryEmailTemplateLoader`), which `sendTemplate` consults only + * as a last resort, when the bundle carries no en-US member at all. */ export interface TemplateLoader { load(name: string, locale: string | undefined): Promise; } /** - * Row shape returned by the loader — mirrors sys_email_template - * columns relevant to rendering. + * What the loader answers: the render input of one resolved `email_template` + * definition, spelled as the `sys_email_template` columns were — the shape + * the renderer has always read. No row backs it any more; the registry + * loader projects the definition into it. */ export interface EmailTemplateRow { name: string; @@ -1394,7 +1397,7 @@ export class EmailService implements IEmailService { } /** - * Render a named template from sys_email_template WITHOUT sending — + * Render a named `email_template` WITHOUT sending — * `IEmailService.renderTemplate` (#9225). Strictly render-only: the shared * resolver above never touches the transport, the queue, persistence or * the outbox; it only resolves `(name, locale)` per the documented ladder, @@ -1407,7 +1410,7 @@ export class EmailService implements IEmailService { } /** - * Render a named template from sys_email_template and deliver via + * Render a named `email_template` and deliver via * send(). Looks up `(name, locale)` then falls back to * `(name, {@link DEFAULT_TEMPLATE_LOCALE})` — the shared * {@link resolveAndRenderTemplate} ladder. diff --git a/packages/plugins/plugin-email/src/email-template-door.test.ts b/packages/plugins/plugin-email/src/email-template-door.test.ts index ac459b6d541..500db523258 100644 --- a/packages/plugins/plugin-email/src/email-template-door.test.ts +++ b/packages/plugins/plugin-email/src/email-template-door.test.ts @@ -7,8 +7,9 @@ * 1. An organization's create, update and predicate update are refused with * `PERMISSION_DENIED` / 403, the refusal names the closed door, and nothing * reaches the driver. - * 2. A system-context write still passes: the seeds, the boot sweep, the live - * projector of a Studio save, and the v18 migration ceremony's promotion. + * 2. A system-context write still passes: the v18 migration ceremony's + * promotion. (The plugin's own writers — the seed, the boot sweep and the + * live projector — are gone: templates resolve from the registry.) * 3. No update marks a row `customized` any more. The provenance stamp that * did is retired, and with the door closed no non-system update reaches the * engine's write at all. @@ -29,7 +30,7 @@ const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; /** An organization admin through the data door: a caller, not system-elevated. */ const ORG_CTX = { userId: 'usr_admin', tenantId: 'org_default', positions: [], permissions: [] }; -/** The platform's own writers (seeds, sweep, projector, promotion). */ +/** A platform writer: the v18 migration ceremony's promotion. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; /** Minimal in-memory driver: what reached it is what the door let through. */ @@ -114,7 +115,7 @@ async function boot() { return { engine, driver }; } -/** A package-seeded and a platform-seeded row, as the boot seeders leave them. */ +/** A package-seeded and a platform-seeded row, as earlier releases' boot seeders left them. */ const SEEDED = [ { id: 'a', name: 'auth.password_reset', locale: 'en-US', subject: 'Reset', managed_by: 'package', customized: false }, { id: 'b', name: 'auth.verify_email', locale: 'en-US', subject: 'Verify', managed_by: 'platform', customized: false }, @@ -185,7 +186,7 @@ describe('the sys_email_template organization door is closed', () => { /* ── 2. system writes pass ─────────────────────────────────────────────── */ describe('a system-context write passes the closed door', () => { - it('creates and updates a template row (the seeds, the sweep, the projector, the promotion)', async () => { + it('creates and updates a template row (the v18 migration ceremony\'s promotion)', async () => { const { engine, driver } = await boot(); await engine.insert(OBJECT, { diff --git a/packages/plugins/plugin-email/src/email-template-door.ts b/packages/plugins/plugin-email/src/email-template-door.ts index bdd2204e0b7..1fa7eebafbb 100644 --- a/packages/plugins/plugin-email/src/email-template-door.ts +++ b/packages/plugins/plugin-email/src/email-template-door.ts @@ -20,12 +20,13 @@ * * ## What passes * - * - **System-context writes** (`isSystem: true`): the built-in seed, the - * declared-template boot sweep, the live projector that writes a Studio save - * of an `email_template` into its sending row, and the v18 migration - * ceremony's promotion. Studio editing of a template is the METADATA door - * (`PUT /api/v1/meta/email_template/:name`), which stays open; its projection - * into this table is a system write. + * - **System-context writes** (`isSystem: true`): the v18 migration + * ceremony's promotion. The plugin itself no longer writes this table — + * templates resolve from the registry (`template-loader.ts`), so the seed, + * the boot sweep and the live projector that used to write here are gone. + * Studio editing of a template is the METADATA door + * (`PUT /api/v1/meta/email_template/:name`), which stays open and lands at + * environment level. * - **A write with no caller at all** — an engine call made with no execution * context, whose hook session is `undefined` ("no caller", never "an * anonymous caller"; see the engine's `buildSession`). It is not an @@ -69,9 +70,9 @@ type DoorVerb = 'create' | 'edit'; export function emailTemplateDoorRefusal(object: string, verb: DoorVerb): Error { const err: any = new Error( `PERMISSION_DENIED: ${object} is closed to organization writes — an organization cannot ${verb} an ` - + 'email template row. Email templates are not overridden per organization: the rows are written by ' - + 'the platform alone, from the built-in templates and the email_template metadata. To change what a ' - + 'template sends, edit the email template in Studio.', + + 'email template row. Email templates are not overridden per organization, and no mail is sent from ' + + 'these rows: every template is read from the built-in templates and the email_template metadata. ' + + 'To change what a template sends, edit the email template in Studio.', ); err.code = 'PERMISSION_DENIED'; err.status = 403; diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 66764896fe2..a586be5f025 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -33,9 +33,8 @@ export { DEFAULT_TEMPLATE_LOCALE, } from './email-service.js'; export { - createSysEmailTemplateLoader, - type TemplateLoaderEngine, - type CreateTemplateLoaderOptions, + createRegistryEmailTemplateLoader, + type EmailTemplateRegistrySources, } from './template-loader.js'; export type { EmailServiceOptions, @@ -107,14 +106,6 @@ export { type EmailTransportProvider, type ApiKeyEmailProvider, } from './transports/index.js'; -export { - bootstrapDeclaredEmailTemplates, - upsertDeclaredEmailTemplate, - deactivateDeclaredEmailTemplate, - mapTemplateToRow, - EMAIL_TEMPLATE_OBJECT, - type BootstrapDeclaredEmailTemplatesResult, -} from './bootstrap-declared-email-templates.js'; export { sweepStrandedOutbox, OUTBOX_OBJECT, diff --git a/packages/plugins/plugin-email/src/plain-text-faces.test.ts b/packages/plugins/plugin-email/src/plain-text-faces.test.ts index f73a800bfd5..6200df01875 100644 --- a/packages/plugins/plugin-email/src/plain-text-faces.test.ts +++ b/packages/plugins/plugin-email/src/plain-text-faces.test.ts @@ -13,8 +13,8 @@ * `/` instead of the accept page. * * The switch lives in the engine (`renderPlainTextTemplate`), not in the - * templates, so these pins run the REAL `EmailService` over the REAL seeded - * built-in rows AND over an authored row nobody hand-braced — a per-template + * templates, so these pins run the REAL `EmailService` over the REAL + * built-in templates AND over an authored row nobody hand-braced — a per-template * `{{{…}}}` patch would pass the first half and fail the second. * * ⚠️ Every expectation is an independent literal: nothing is derived from the @@ -46,7 +46,7 @@ class CaptureTransport implements IEmailTransport { } } -/** Exactly what `EmailServicePlugin` seeds, matched on `(name, locale)` with no fallback of its own. */ +/** Exactly what `EmailServicePlugin` registers, matched on `(name, locale)` with no fallback of its own. */ function seededRow(name: string, locale: string): EmailTemplateRow | null { const hit = BUILTIN_AUTH_TEMPLATES.find((t) => t.name === name && t.locale === locale); if (!hit) return null; diff --git a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts index b16015ec1e6..154673d2bab 100644 --- a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts +++ b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts @@ -4,130 +4,62 @@ * [#10772] `await kernel.shutdown()` must actually reach `EmailServicePlugin`'s * teardown. * - * THE DEFECT. The plugin arms a live `email_template` bridge at - * `kernel:ready`: a metadata subscription, a protocol mutation listener, a - * provenance hook bound to the data engine, and (when SMTP is configured) an - * open transport. The teardown that released all of them was spelled - * `dispose()`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, + * THE DEFECT. The plugin armed a live `email_template` bridge at + * `kernel:ready` — a metadata subscription, a protocol mutation listener, a + * provenance hook bound to the data engine — and (when SMTP is configured) an + * open transport. The teardown that released them was spelled `dispose()`. + * `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, * `start?(ctx)` and `destroy?()` — and NO `dispose()` — and * `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins - * in reverse calling `plugin.destroy()`. Measured on the same revision: - * `dispose()` had exactly ONE caller in the whole repo, a test in this - * package. The kernel was never one of them, so after `await - * kernel.shutdown()` had RESOLVED the bridge was still armed and still - * writing. + * in reverse calling `plugin.destroy()`, so after `await kernel.shutdown()` + * had RESOLVED the bridge was still armed and still writing. * - * THE SPELLING. This member and `WebhookOutboxPlugin` are the `dispose()` - * half of the family — the "seventh spelling" the #10619 gate's roster was - * widened for BEFORE any instance of it was known, already present when the - * roster was measured. A census that looked only for `stop()` misses them - * entirely, which is the other half of why #10371's enumeration came out - * short. + * WHAT IS LEFT TO RELEASE. Templates now resolve from the registry at every + * send (ADR-0131 D6/D10), so there is no bridge: no subscription, no listener, + * no projector. What `kernel:ready` still binds to the data engine is the + * closed `sys_email_template` organization door, and that binding is what + * these pins drive through a real `LiteKernel` bootstrap and shutdown. * - * WHY THE ASSERTIONS ARE BEHAVIOURAL. `expect(plugin.destroy).toBeDefined()` - * would pass on a plugin the kernel still never reaches. These drive a real - * `LiteKernel` through a real bootstrap and a real shutdown and then perform a - * real runtime write, reading the rows that did or did not land. + * EVERY PRE-SHUTDOWN LEG IS A POSITIVE CONTROL: without it a door that never + * bound would satisfy the post-shutdown assertion vacuously. * - * EVERY PRE-SHUTDOWN LEG IS A POSITIVE CONTROL: without it a bridge that never - * armed would satisfy the post-shutdown assertion vacuously. - * - * THE `dispose()` LEG IS THE OTHER DIRECTION. The repair keeps `dispose()` as - * a delegating alias because it is public API of an exported class and an - * embedder — here, a test in this very package — calls it directly. Pinning - * only the shutdown direction would go green on an implementation that simply - * deletes `dispose()`. + * THE `dispose()` LEG IS THE OTHER DIRECTION. `dispose()` stays a delegating + * alias because it is public API of an exported class; pinning only the + * shutdown direction would go green on an implementation that deletes it. */ import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { EmailServicePlugin } from './email-plugin.js'; -const TABLE = 'sys_email_template'; type AnyRecord = Record; +type Hook = { event: string; handler: (ctx: AnyRecord) => unknown; packageId?: string }; -/** - * The slice of ObjectQL the template bridge and the organization door touch — - * the same double `email-plugin.template-runtime-write.test.ts` uses, so this - * file cannot accept a dispatch shape the real engine would refuse. - */ +/** The slice of ObjectQL the organization door binds to: its hook table. */ function fakeEngine() { - const rows: AnyRecord[] = []; - const matches = (row: AnyRecord, cond?: AnyRecord) => - !cond || - Object.entries(cond).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return row[k] === v; - }); + const hooks: Hook[] = []; return { - rows, - _registry: { listItems: () => [] }, - async find(object: string, q?: AnyRecord) { - if (object !== TABLE) return []; - const out = rows.filter((r) => matches(r, q?.where ?? q?.filter)); - return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; - }, - async insert(_object: string, row: AnyRecord) { - rows.push({ ...row }); - return { id: row.id }; - }, - async update(_object: string, data: AnyRecord, options?: AnyRecord) { - const dispatch = assertEngineUpdateDispatch(data, options); - if (dispatch.kind !== 'by-id') throw new Error(`unexpected update dispatch: ${dispatch.kind}`); - const target = rows.find((r) => r.id === dispatch.id); - if (target) Object.assign(target, data); - return { affected: target ? 1 : 0 }; - }, - registerHook() { /* organization door */ }, - unregisterHooksByPackage() { return 0; }, - }; -} - -/** Mirrors `ObjectStackProtocolImplementation`'s two registration seams. */ -function fakeProtocol() { - const projectors = new Map Promise>(); - const listeners: Array<(evt: AnyRecord) => void> = []; - const p: AnyRecord = { - projectorFailures: [] as string[], - registerMutationProjector: (type: string, fn: (evt: AnyRecord) => Promise) => { - projectors.set(type, fn); + hooks, + registry: { listItems: () => [] }, + async find() { return []; }, + registerHook(event: string, handler: Hook['handler'], options?: AnyRecord) { + hooks.push({ event, handler, packageId: options?.packageId }); }, - onMetadataMutation: (fn: (evt: AnyRecord) => void) => { - listeners.push(fn); - return () => { - const i = listeners.indexOf(fn); - if (i >= 0) listeners.splice(i, 1); - }; + unregisterHooksByPackage(packageId: string) { + const before = hooks.length; + for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i].packageId === packageId) hooks.splice(i, 1); + return before - hooks.length; }, - async announce(evt: AnyRecord) { - const projector = projectors.get(evt.type); - if (projector) { - // Mirrors `runMutationProjector`: a throw is caught and - // reported on the write's response, never propagated. - try { await projector(evt); } - catch (e: any) { p.projectorFailures.push(String(e?.message ?? e)); } + /** An organization's create, as the engine runs it: every `beforeInsert` hook first. */ + async organizationCreate() { + for (const h of hooks.filter((x) => x.event === 'beforeInsert')) { + await h.handler({ session: { userId: 'u1', organizationId: 'org_1' } }); } - for (const l of [...listeners]) l(evt); - await new Promise((r) => setTimeout(r, 0)); }, - /** A `PUT /api/v1/meta/email_template/:name` that landed. */ - save: (name: string, body: unknown) => - p.announce({ type: 'email_template', name, state: 'active', body }), }; - return p; } -const template = () => ({ - name: 'auth.password_reset', - label: 'Password Reset', - category: 'auth', - locale: 'en-US', - subject: 'Reset your password, {{user.name}}', - bodyHtml: '

Click here

', -}); - /** Registers the collaborators the email plugin resolves. Nothing under test. */ class FixturePlugin implements Plugin { name = 'com.objectstack.engine.objectql'; @@ -135,16 +67,11 @@ class FixturePlugin implements Plugin { version = '1.0.0'; providesServices = ['objectql', 'manifest', 'metadata', 'protocol']; readonly engine = fakeEngine(); - readonly protocol = fakeProtocol(); init(ctx: PluginContext): void { ctx.registerService('objectql', this.engine); ctx.registerService('manifest', { register: () => {}, list: () => [] }); - ctx.registerService('metadata', { - list: () => [], - get: async () => undefined, - subscribe: () => () => {}, - }); - ctx.registerService('protocol', this.protocol); + ctx.registerService('metadata', { list: () => [], get: async () => undefined, subscribe: () => () => {} }); + ctx.registerService('protocol', { getMetaItemsForExecution: async () => ({ items: [] }) }); } } @@ -152,34 +79,25 @@ async function boot() { const kernel = new LiteKernel({ logger: { level: 'error' } }); const fixture = new FixturePlugin(); kernel.use(fixture); - const plugin = new EmailServicePlugin({ seedTemplates: false }); + const plugin = new EmailServicePlugin(); kernel.use(plugin); await kernel.bootstrap(); return { kernel, plugin, fixture }; } -const rowsOf = (engine: AnyRecord) => - engine.rows.filter((r: AnyRecord) => r.name === 'auth.password_reset'); - -describe('#10772 EmailServicePlugin detaches its template bridge on kernel shutdown', () => { - it('stops materializing runtime writes once shutdown() has resolved', async () => { +describe('#10772 EmailServicePlugin releases what it binds on kernel shutdown', () => { + it('unbinds the sys_email_template organization door once shutdown() has resolved', async () => { const { kernel, fixture } = await boot(); - // POSITIVE CONTROL — the live bridge really is armed, so the assertion - // below measures a detachment and not a bridge that never worked. - await fixture.protocol.save('auth.password_reset', template()); - expect(fixture.protocol.projectorFailures).toEqual([]); - expect(rowsOf(fixture.engine)).toHaveLength(1); - fixture.engine.rows.length = 0; + // POSITIVE CONTROL — the door really is bound, so the assertion below + // measures a release and not a door that never closed. + await expect(fixture.engine.organizationCreate()).rejects.toThrow(/^PERMISSION_DENIED/); await kernel.shutdown(); - await fixture.protocol.save('auth.password_reset', template()); - - // THE PIN. Before the fix this wrote another row: the kernel had no - // `destroy()` to call, and `dispose()`'s only caller in the entire - // repo was a test. - expect(rowsOf(fixture.engine)).toHaveLength(0); + // THE PIN. Before #10772 the kernel had no `destroy()` to call. + expect(fixture.engine.hooks).toEqual([]); + await expect(fixture.engine.organizationCreate()).resolves.toBeUndefined(); }); it('the kernel reaches destroy() during shutdown', async () => { @@ -199,21 +117,17 @@ describe('#10772 EmailServicePlugin detaches its template bridge on kernel shutd it('the retained dispose() alias still tears down for an embedder that calls it directly', async () => { const { plugin, fixture } = await boot(); - await fixture.protocol.save('auth.password_reset', template()); - expect(rowsOf(fixture.engine)).toHaveLength(1); - fixture.engine.rows.length = 0; + expect(fixture.engine.hooks).not.toEqual([]); await plugin.dispose(); - await fixture.protocol.save('auth.password_reset', template()); - - expect(rowsOf(fixture.engine)).toHaveLength(0); + expect(fixture.engine.hooks).toEqual([]); }); it('a teardown on a plugin the kernel never started is a no-op rather than a throw', async () => { // Idempotence matters because `destroy()` clears the handles it // released; the kernel calls it on every plugin it walks. - const plugin = new EmailServicePlugin({ seedTemplates: false }); + const plugin = new EmailServicePlugin(); await expect(plugin.destroy()).resolves.toBeUndefined(); await expect(plugin.destroy()).resolves.toBeUndefined(); await expect(plugin.dispose()).resolves.toBeUndefined(); diff --git a/packages/plugins/plugin-email/src/sender-address-validation.test.ts b/packages/plugins/plugin-email/src/sender-address-validation.test.ts index ba24028c144..4f402db5050 100644 --- a/packages/plugins/plugin-email/src/sender-address-validation.test.ts +++ b/packages/plugins/plugin-email/src/sender-address-validation.test.ts @@ -83,7 +83,7 @@ async function boot(mailValues: Record, opts: any = {}) { }), }; const ctx = fakeCtx(services); - const plugin = new EmailServicePlugin({ provider: 'log', seedTemplates: false, ...opts }); + const plugin = new EmailServicePlugin({ provider: 'log', ...opts }); await plugin.init(ctx as never); await plugin.start(ctx as never); await ctx.fire('kernel:ready'); @@ -101,7 +101,6 @@ describe('EmailServicePlugin.init — declared default sender', () => { it('refuses to boot with the exact OS_EMAIL_FROM shape that shipped the defect', async () => { const plugin = new EmailServicePlugin({ provider: 'log', - seedTemplates: false, defaultFrom: { name: 'ObjectOS Local', address: 'noreply@localhost' }, }); await expect(plugin.init(ctxFor() as never)).rejects.toThrow(/noreply@localhost/); @@ -110,7 +109,6 @@ describe('EmailServicePlugin.init — declared default sender', () => { it('names the consequence and the fix, not just the address', async () => { const plugin = new EmailServicePlugin({ provider: 'log', - seedTemplates: false, defaultFrom: 'noreply@localhost', }); const err = await plugin.init(ctxFor() as never).then( @@ -125,7 +123,7 @@ describe('EmailServicePlugin.init — declared default sender', () => { }); it('refuses an address with no domain at all', async () => { - const plugin = new EmailServicePlugin({ provider: 'log', seedTemplates: false, defaultFrom: 'noreply' }); + const plugin = new EmailServicePlugin({ provider: 'log', defaultFrom: 'noreply' }); await expect(plugin.init(ctxFor() as never)).rejects.toThrow(/not a valid email address/); }); @@ -133,14 +131,13 @@ describe('EmailServicePlugin.init — declared default sender', () => { const ctx = ctxFor(); const plugin = new EmailServicePlugin({ provider: 'log', - seedTemplates: false, defaultFrom: { name: 'ObjectOS Local', address: 'no-reply@objectstack.local' }, }); await expect(plugin.init(ctx as never)).resolves.toBeUndefined(); }); it('boots with NO declared sender — callers may always pass input.from', async () => { - const plugin = new EmailServicePlugin({ provider: 'log', seedTemplates: false }); + const plugin = new EmailServicePlugin({ provider: 'log' }); await expect(plugin.init(ctxFor() as never)).resolves.toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-email/src/template-loader.test.ts b/packages/plugins/plugin-email/src/template-loader.test.ts new file mode 100644 index 00000000000..9406cdc7fc4 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-loader.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The registry email-template loader (ADR-0131 D6/D10, ruling C on §6 Q1). + * + * This file used to pin `bootstrapDeclaredEmailTemplates`, the bridge that + * copied declared `email_template` metadata into the `sys_email_template` rows + * `sendTemplate` read (#4509). The rows are gone from the send path, so each + * pin that asked "does the AUTHORED wording reach the send?" is restated here + * against the read that replaced them: + * + * - a declaration materialized with the shared mapping → it resolves with its + * authored wording, `bodyHtml` included; + * - rows keyed by `(name, locale)` → bundle members resolve one by one; + * - the effective template the door serves is what got projected (#21785) → + * it is what resolves, read in no organization (ADR-0131 D6); + * - a failed effective read projected nothing, never the package layer → a + * failed read propagates, never answered from the registry; + * - the live runtime-write path took effect without a restart → every load + * re-reads, so a save is live on the next send; + * - a withdrawn template's rows were deactivated → it resolves to nothing; + * - optional columns were omitted, never nulled → so are the render input's; + * - the `content` alias reached the schema as a document (#8378) → it still + * does, and the refusal is LOUD: a malformed item is never skipped down to + * the built-in copy. + * + * The row-only mechanics — seed-not-clobber, provenance adoption, the bulk read + * of stored rows (#22062) — retired with the rows they managed; the plugin + * writes no `sys_email_template` row at all (pinned in + * `email-plugin.template-runtime-write.test.ts`). + */ + +import { describe, expect, it } from 'vitest'; +import { + BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, + createRegistryEmailTemplateLoader, +} from './template-loader.js'; + +type AnyRecord = Record; + +const APP_PACKAGE = 'com.example.app'; +const AUTHORED = 'Reset your password, {{user.name}}'; +const BUILTIN = 'Built-in: reset your password'; +const OVERLAY = 'Admin reworded: reset for {{user.name}}'; + +function declared(over: AnyRecord = {}): AnyRecord { + return { + name: 'auth.password_reset', + label: 'Password Reset', + category: 'auth', + subject: AUTHORED, + bodyHtml: '

Click here

', + variables: [{ name: 'url', type: 'string', required: true }], + _packageId: APP_PACKAGE, + ...over, + }; +} + +const builtin = (over: AnyRecord = {}) => + declared({ subject: BUILTIN, _packageId: BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID, ...over }); + +/** + * The protocol's execution list as the metadata door serves it: one item per + * `(package, name, locale)` slot, the environment's item already merged over + * the declaration it overrides. Every request is recorded, so what the loader + * asked for is asserted rather than assumed. `items` is read at call time. + */ +function layeredProtocol(items: AnyRecord[] | (() => AnyRecord[])) { + const requests: AnyRecord[] = []; + return { + requests, + async getMetaItemsForExecution(request: { type: string }) { + requests.push({ ...request }); + const list = typeof items === 'function' ? items() : items; + return { type: request.type, items: list.map((i) => ({ ...i })) }; + }, + }; +} + +function registry(items: AnyRecord[]) { + const reads: string[] = []; + return { reads, listItems: (type: string) => { reads.push(type); return items.map((i) => ({ ...i })); } }; +} + +describe('createRegistryEmailTemplateLoader — what it reads', () => { + it('resolves a declaration with its AUTHORED wording, read in no organization', async () => { + const protocol = layeredProtocol([declared()]); + const loader = createRegistryEmailTemplateLoader({ protocol }); + + const found = await loader.load('auth.password_reset', 'en-US'); + + // [ADR-0131 D6] The read names no organization: environment → code. + expect(protocol.requests).toEqual([{ type: 'email_template' }]); + expect(found).toMatchObject({ + name: 'auth.password_reset', + locale: 'en-US', + subject: AUTHORED, + body_html: '

Click here

', + active: true, + }); + expect(JSON.parse(found!.variables_json!)).toEqual([{ name: 'url', type: 'string', required: true }]); + }); + + it('keys by (name, locale): each member of a bundle resolves on its own', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([ + declared({ locale: 'zh-CN', subject: '重置密码' }), + declared({ locale: 'en-US' }), + ]), + }); + + expect((await loader.load('auth.password_reset', 'zh-CN'))?.subject).toBe('重置密码'); + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + expect(await loader.load('auth.password_reset', 'ja-JP')).toBeNull(); + }); + + it('resolves the environment item the door serves over the declaration (#21785)', async () => { + // The layered list has already put the Studio save over the package's + // declaration in its slot; the loader takes what the door serves. + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([declared({ subject: OVERLAY })]), + registry: registry([declared()]), + }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(OVERLAY); + }); + + it('propagates a failed read — never answers it from the registry\'s package wording', async () => { + const reg = registry([declared()]); + const loader = createRegistryEmailTemplateLoader({ + protocol: { async getMetaItemsForExecution(): Promise { throw new Error('sys_metadata read failed'); } }, + registry: reg, + }); + + await expect(loader.load('auth.password_reset', 'en-US')).rejects.toThrow('sys_metadata read failed'); + expect(reg.reads).toEqual([]); + }); + + it('reads the registry only when no protocol is registered (a host with no metadata door)', async () => { + const reg = registry([declared()]); + const loader = createRegistryEmailTemplateLoader({ registry: reg }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + expect(reg.reads).toEqual(['email_template']); + }); + + it('re-reads at every load, so a save is live on the next send with no projection and no restart', async () => { + let items = [declared()]; + const protocol = layeredProtocol(() => items); + const loader = createRegistryEmailTemplateLoader({ protocol }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + items = [declared({ subject: OVERLAY })]; + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(OVERLAY); + expect(protocol.requests).toHaveLength(2); + }); + + it('resolves the sources at every load, so a protocol registered after wiring is seen', async () => { + let sources: AnyRecord = { registry: registry([declared()]) }; + const loader = createRegistryEmailTemplateLoader(() => sources); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + sources = { ...sources, protocol: layeredProtocol([declared({ subject: OVERLAY })]) }; + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(OVERLAY); + }); + + it('strips the read decorations the strict schema refuses, keeping the declared envelope', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([declared({ _diagnostics: { valid: true }, _draft: false, _provenance: 'package' })]), + }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + }); + + it('a withdrawn template resolves to nothing', async () => { + let items = [declared()]; + const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol(() => items) }); + expect(await loader.load('auth.password_reset', 'en-US')).not.toBeNull(); + + items = []; + expect(await loader.load('auth.password_reset', 'en-US')).toBeNull(); + expect(await loader.load('auth.password_reset', undefined)).toBeNull(); + }); +}); + +describe('createRegistryEmailTemplateLoader — which item wins one (name, locale)', () => { + it('an app\'s declaration wins over the built-in copy, in either list order', async () => { + for (const items of [[builtin(), declared()], [declared(), builtin()]]) { + const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol(items) }); + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + } + }); + + it('a package-less environment item wins over the built-in copy', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([builtin(), declared({ subject: OVERLAY, _packageId: undefined })]), + }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(OVERLAY); + }); + + it('the built-in still serves a locale the app does not declare', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([builtin(), builtin({ locale: 'zh-CN', subject: '内置' }), declared()]), + }); + + expect((await loader.load('auth.password_reset', 'en-US'))?.subject).toBe(AUTHORED); + expect((await loader.load('auth.password_reset', 'zh-CN'))?.subject).toBe('内置'); + }); +}); + +describe('createRegistryEmailTemplateLoader — the render input', () => { + it('omits optional keys rather than nulling them, so the text part is derived only when no bodyText was authored', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([declared({ name: 'ops.digest', category: 'notification', variables: [] })]), + }); + + const found = await loader.load('ops.digest', 'en-US'); + + expect(found).not.toHaveProperty('body_text'); + expect(found).not.toHaveProperty('reply_to'); + expect(found).not.toHaveProperty('from_address'); + expect(found).not.toHaveProperty('from_name'); + expect(found).not.toHaveProperty('variables_json'); + }); + + it('carries every authored envelope key the sender reads', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([declared({ + bodyText: 'Reset: {{url}}', + fromOverride: { name: 'Security', address: 'security@example.test' }, + replyTo: 'help@example.test', + active: false, + })]), + }); + + expect(await loader.load('auth.password_reset', 'en-US')).toMatchObject({ + body_text: 'Reset: {{url}}', + from_address: 'security@example.test', + from_name: 'Security', + reply_to: 'help@example.test', + active: false, + }); + }); + + it('applies the schema defaults: a declaration naming no locale and no `active` is an active en-US member', async () => { + const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol([declared()]) }); + + expect(await loader.load('auth.password_reset', 'en-US')).toMatchObject({ locale: 'en-US', active: true }); + }); +}); + +/** + * `content` is a spelling an author really can write on an email template — + * `EmailTemplateDefinitionSchema` lists it in its `strictObject` aliases table + * (`content: 'bodyHtml'`). That table is a REJECTION facility, not a + * conversion: it builds the message of the `unrecognized_keys` refusal. It + * reaches this read only through a door that skips validation (a + * `defineStack(…, { strict: false })` load, a hand-built manifest), and the + * refusal must then carry the author's fix — and must not fall through to the + * built-in copy of the same slot, the mail the author believed they replaced. + */ +describe('createRegistryEmailTemplateLoader — a malformed item is refused, never skipped (#8378)', () => { + it('refuses the `content` spelling with the `content` → `bodyHtml` fix, over a built-in of the same slot', async () => { + // `bodyHtml` deliberately absent — `content` is the author's attempt at it. + const malformed: AnyRecord = { ...declared(), content: '

Welcome

' }; + delete malformed.bodyHtml; + const loader = createRegistryEmailTemplateLoader({ protocol: layeredProtocol([builtin(), malformed]) }); + + const err = await loader.load('auth.password_reset', 'en-US').then( + () => { throw new Error('the malformed template resolved'); }, + (e: Error) => e, + ); + expect(err.message).toContain('auth.password_reset'); + expect(err.message).toContain('content'); + expect(err.message).toContain('bodyHtml'); + expect(err.message).not.toContain('expected object, received string'); + }); + + it('does not silently vanish when `content` is the empty string', async () => { + const loader = createRegistryEmailTemplateLoader({ + protocol: layeredProtocol([{ name: 'ops.digest', label: 'Digest', subject: 'Daily', content: '' }]), + }); + + await expect(loader.load('ops.digest', 'en-US')).rejects.toThrow(/ops\.digest/); + }); +}); diff --git a/packages/plugins/plugin-email/src/template-loader.ts b/packages/plugins/plugin-email/src/template-loader.ts index e30c6e71af0..f142dd8e075 100644 --- a/packages/plugins/plugin-email/src/template-loader.ts +++ b/packages/plugins/plugin-email/src/template-loader.ts @@ -1,136 +1,38 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The `sys_email_template` {@link TemplateLoader} — locale resolution that - * answers from the DECLARED contract instead of from driver row order (#7731). - * - * ## What was wrong - * - * The loader used to be four lines inline in `EmailServicePlugin`: build - * `where = { name }`, add `where.locale` only when a locale was passed, then - * `find(..., { limit: 1 })` with **no ordering**. "First row of an unordered - * set" is whatever the driver happens to yield — so an i18n bundle with `en-US` - * and `zh-CN` rows under one name rendered `zh-CN` for a caller that named no - * locale at all, on two consecutive fresh boots. Nothing in the system declares - * that; three separate places declare the opposite: - * - * - `SendTemplateInput.locale` (spec contract): *"Falls back to `'en-US'`"*. - * - `EmailTemplateDefinitionSchema.locale`: the resolver *"matches - * `(name, locale)` **exactly**"* and, for a call that named a locale, - * retries exactly one rung — `EMAIL_TEMPLATE_FLOOR_LOCALE`. - * - `sys_email_template`'s own object doc: resolved by an exact - * `(name, locale)` match with that same single `en-US` rung. - * - * Those last two bullets are CITED, not quoted in full, and deliberately: - * both sentences once read "picks the best match for the recipient's locale" - * / "picks the best-matching locale for the recipient" — the claim the rule - * below says the resolver has never implemented. They were corrected to the - * ladder as built (#18499), together with `sys_notification_template.locale` - * and the platform checklist's locale clause; a quotation is what stranded - * them here in the first place. - * - * ## The rule this implements - * - * Every branch below pins the row it wants in the `where` clause; none of them - * asks the store to pick a locale for us: - * - * 1. **A locale was named** → exact `(name, locale)` match, or `null`. The - * en-US fallback stays where it is documented — in `sendTemplate`'s ladder - * — so a replacement loader cannot silently relocate it. - * 2. **No locale** → `(name, 'en-US')`, the documented default. This is the - * #7731 fix: the *query* names en-US, so even a driver that honours no - * ordering at all cannot answer with a different locale. - * 3. **No locale and no en-US row** → the bundle's lowest locale tag, ordered. - * A store holding only `zh-CN` rows worked before this change (there was - * exactly one row to pick arbitrarily from), and refusing it now would - * trade one bug for an outage. Ordered rather than arbitrary, so the answer - * is the same on every boot. + * The email-template {@link TemplateLoader}: `email_template` metadata, read + * from the registry by `(name, locale)` (ADR-0131 D6/D10, ruling C on §6 Q1). + * + * Templates used to be copied into `sys_email_template` rows — the built-ins + * by a boot seed, declared and Studio-authored ones by a boot sweep and a live + * projector — and resolved from there. The rows are gone from the send path: + * every template is read where it is authored, so there is one copy of it. + * + * ## The locale rule + * + * Answered from the DECLARED contract, never from storage order (#7731) — + * `SendTemplateInput.locale` (spec contract) and + * `EmailTemplateDefinitionSchema.locale` carry the ladder of record: + * + * 1. **A locale was named** → the exact `(name, locale)` member, or `null`. + * The `en-US` fallback stays where it is documented — in `EmailService`'s + * ladder — so a replacement loader cannot silently relocate it. + * 2. **No locale** → `(name, 'en-US')`, the documented default. A bundle + * carrying `en-US` and `zh-CN` answers `en-US` to a caller that named no + * locale, whatever order the members were declared in. + * 3. **No locale and no `en-US` member** → the bundle's lowest locale tag. A + * single-locale bundle keeps working rather than resolving to nothing, and + * the answer is the same on every boot. * * Language-only prefix matching (`zh` → `zh-CN`) is deliberately NOT here: no * contract declares it, and inventing it would put a resolution rule in the * code that no spec, doc or form describes. - * - * Ordering is spelled `orderBy` — the canonical engine key. `sort` is the wire - * spelling and `find()` rejects it outright, so a tie-break has to be spelled - * this way to exist at all. */ -import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; -import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import { EmailTemplateDefinitionSchema, type EmailTemplateDefinitionParsed } from '@objectstack/spec/system'; - -/** System read context — template resolution is not an end-user query. */ -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - -/** - * The slice of the ObjectQL engine this loader calls. Declared here rather than - * imported as `IDataEngine` so the seam a test has to fake is one method. - */ -export interface TemplateLoaderEngine { - find(object: string, query: Record): Promise; -} - -/** Sort node shape as the engine spells it (`orderBy`, never the wire `sort`). */ -interface TemplateSort { field: string; order: 'asc' | 'desc' } - -/** - * Tie-break for a query that already pins `(name, locale)`: duplicates of one - * locale (an org overlay row landing beside the platform one) must still - * resolve the same way on every boot. - */ -const BY_ID: readonly TemplateSort[] = [{ field: 'id', order: 'asc' }]; - -/** Deterministic order for the unpinned last resort — lowest locale tag wins. */ -const BY_LOCALE: readonly TemplateSort[] = [ - { field: 'locale', order: 'asc' }, - { field: 'id', order: 'asc' }, -]; - -/** Options for {@link createSysEmailTemplateLoader}. */ -export interface CreateTemplateLoaderOptions { - /** Backing object name; overridable for tests. */ - object?: string; -} - -/** - * Build the `sys_email_template` loader `EmailService` resolves templates - * through. See the module doc for the resolution rule it implements. - */ -export function createSysEmailTemplateLoader( - engine: TemplateLoaderEngine, - options: CreateTemplateLoaderOptions = {}, -): TemplateLoader { - const object = options.object ?? EMAIL_TEMPLATE_OBJECT; - - const first = async ( - where: Record, - orderBy: readonly TemplateSort[], - ): Promise => { - const rows = await engine.find(object, { - where, - orderBy: orderBy.map((s) => ({ ...s })), - limit: 1, - context: SYSTEM_CTX, - }); - const row = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0]; - return (row as EmailTemplateRow) || null; - }; - - return { - async load(name, locale) { - if (locale) return first({ name, locale }, BY_ID); - const preferred = await first({ name, locale: DEFAULT_TEMPLATE_LOCALE }, BY_ID); - if (preferred) return preferred; - // No en-US row in this bundle — rule 3 in the module doc. - return first({ name }, BY_LOCALE); - }, - }; -} - -// ============================================================================ -// The registry loader (ADR-0131 D6/D10, ruling C on §6 Q1) -// ============================================================================ +import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; /** * The package the built-in auth templates are code-declared under: the email @@ -214,17 +116,12 @@ function toRenderInput(tpl: EmailTemplateDefinitionParsed): EmailTemplateRow { * winner cannot be told from the loser there. The layered list has already * decided it. * - * ## The resolution rule (unchanged from the row loader it replaces) - * - * 1. **A locale was named** → that exact `(name, locale)`, or `null`. The - * `en-US` fallback stays in `EmailService`'s ladder. - * 2. **No locale** → `(name, 'en-US')`. - * 3. **No locale and no `en-US` member** → the bundle's lowest locale tag. + * ## Which item wins one `(name, locale)` * - * Within one `(name, locale)`, an item from any other package — or from no - * package — wins over the built-in: an app that declares `auth.password_reset` - * overrides the platform's copy, and an environment item stands in for every - * package slot it overrides. + * The locale rule is the module doc's. Within one `(name, locale)`, an item + * from any other package — or from no package — wins over the built-in: an + * app that declares `auth.password_reset` overrides the platform's copy, and + * an environment item stands in for every package slot it overrides. * * A failed read propagates. Answering it from the registry would serve the * package wording over every environment item on a transient storage error; diff --git a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts index d0e5dec8814..be31b010330 100644 --- a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts +++ b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts @@ -7,14 +7,14 @@ // boots — because the loader's no-locale query was an unordered `limit: 1` and // the service's en-US fallback only ran when a locale HAD been named. Both // seams are covered here, and the determinism assertions run the same bundle -// through drivers that disagree about row order — including one that honours -// no ordering at all — because "returns en-US on this machine today" is the -// property the old code also had. +// in both declaration orders, because "returns en-US on this machine today" is +// the property the old code also had. The loader now reads `email_template` +// metadata from the registry (ADR-0131 D6/D10) instead of `sys_email_template` +// rows; its rule is the one these pinned against the row loader, unchanged. import { describe, it, expect } from 'vitest'; import { EmailService, DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; -import { createSysEmailTemplateLoader, type TemplateLoaderEngine } from './template-loader.js'; -import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js'; +import { createRegistryEmailTemplateLoader } from './template-loader.js'; import { EmailServicePlugin } from './email-plugin.js'; import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; @@ -44,41 +44,22 @@ function row(locale: string, id = `row-${locale}`): Row { const EN = row('en-US'); const ZH = row('zh-CN'); -const FR = row('fr-FR'); -interface FakeEngineOptions { - /** Emulate a driver that ignores `orderBy` and answers in storage order. */ - ignoreOrderBy?: boolean; +/** One `email_template` member of the `welcome` bundle, as the registry lists it. */ +function item(locale: string, over: Record = {}): Record { + return { + name: 'welcome', + label: 'Welcome', + locale, + subject: `[${locale}] Hello {{name}}`, + bodyHtml: `

[${locale}] Hi {{name}}

`, + ...over, + }; } -/** - * A driver-ish engine: filters by `where`, applies `orderBy` when it honours - * it, then `limit`. Rows are answered in the order they were handed in, so a - * test can make "storage order" whatever it likes. - */ -function fakeEngine(rows: Row[], options: FakeEngineOptions = {}) { - const queries: Array> = []; - const engine: TemplateLoaderEngine & { queries: typeof queries } = { - queries, - async find(object: string, query: Record) { - expect(object).toBe(EMAIL_TEMPLATE_OBJECT); - queries.push(query); - const where = (query.where ?? {}) as Record; - let out = rows.filter((r) => Object.entries(where).every(([k, v]) => (r as any)[k] === v)); - if (!options.ignoreOrderBy && Array.isArray(query.orderBy)) { - out = [...out].sort((a, b) => { - for (const { field, order } of query.orderBy as Array<{ field: string; order: string }>) { - const av = String((a as any)[field] ?? ''); - const bv = String((b as any)[field] ?? ''); - if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1); - } - return 0; - }); - } - return typeof query.limit === 'number' ? out.slice(0, query.limit) : out; - }, - }; - return engine; +/** The registry loader over a list answered in exactly the order it was handed in. */ +function registryLoader(items: Array>) { + return createRegistryEmailTemplateLoader({ registry: { listItems: () => items.map((i) => ({ ...i })) } }); } /** A loader that ONLY does exact matches — no fallback of its own. */ @@ -102,68 +83,53 @@ function serviceWith(loader: TemplateLoader) { return { svc, transport }; } -// ── the loader seam (email-plugin.ts ~:548) ──────────────────────────────── +// ── the loader seam ──────────────────────────────────────────────────────── -describe('createSysEmailTemplateLoader — no locale means en-US, not "first row"', () => { - // The regression, both ways round: which row the store happens to hold +describe('the registry loader — no locale means en-US, not "first member"', () => { + // The regression, both ways round: which member the list happens to carry // first is exactly the input the old code was reading. it.each([ - ['en-US stored first', [EN, ZH]], - ['zh-CN stored first', [ZH, EN]], - ])('resolves the en-US row when no locale is passed (%s)', async (_label, rows) => { - const loader = createSysEmailTemplateLoader(fakeEngine(rows as Row[])); - const found = await loader.load('welcome', undefined); + ['en-US declared first', [item('en-US'), item('zh-CN')]], + ['zh-CN declared first', [item('zh-CN'), item('en-US')]], + ])('resolves the en-US member when no locale is passed (%s)', async (_label, items) => { + const found = await registryLoader(items).load('welcome', undefined); expect(found?.locale).toBe('en-US'); }); - it('pins en-US in the WHERE clause, so a driver that ignores orderBy still answers en-US', async () => { - const engine = fakeEngine([ZH, EN], { ignoreOrderBy: true }); - const loader = createSysEmailTemplateLoader(engine); - - expect((await loader.load('welcome', undefined))?.locale).toBe('en-US'); - expect(engine.queries[0].where).toEqual({ name: 'welcome', locale: DEFAULT_TEMPLATE_LOCALE }); - // …and it never asks an unordered question. - for (const q of engine.queries) expect(q.orderBy).toBeTruthy(); - }); - - it('never sends the wire spelling `sort` — find() rejects it, so the tie-break must be orderBy', async () => { - const engine = fakeEngine([EN, ZH]); - await createSysEmailTemplateLoader(engine).load('welcome', undefined); - for (const q of engine.queries) expect(q.sort).toBeUndefined(); - }); - it('an explicit locale is an EXACT match — no fallback of the loader\'s own', async () => { - const loader = createSysEmailTemplateLoader(fakeEngine([EN, ZH])); + const loader = registryLoader([item('en-US'), item('zh-CN')]); expect((await loader.load('welcome', 'zh-CN'))?.locale).toBe('zh-CN'); - // fr-FR has no row: the loader answers null and leaves the en-US fallback - // to sendTemplate's ladder, where it is documented. + // fr-FR has no member: the loader answers null and leaves the en-US + // fallback to sendTemplate's ladder, where it is documented. expect(await loader.load('welcome', 'fr-FR')).toBeNull(); }); - it('resolves the same row on repeat calls, with duplicate rows for one locale', async () => { - const dupes = [ - { ...row('en-US', 'row-b'), subject: 'B' }, - { ...row('en-US', 'row-a'), subject: 'A' }, - ]; - const loader = createSysEmailTemplateLoader(fakeEngine(dupes)); + it('does no language-subtag folding: `en` is not `en-US`, in either direction', async () => { + expect(await registryLoader([item('en-US')]).load('welcome', 'en')).toBeNull(); + expect(await registryLoader([item('en')]).load('welcome', 'en-US')).toBeNull(); + }); + + it('resolves the same member on repeat calls when two packages declare one slot', async () => { + const loader = registryLoader([ + item('en-US', { subject: 'A', _packageId: 'com.example.a' }), + item('en-US', { subject: 'B', _packageId: 'com.example.b' }), + ]); const first = await loader.load('welcome', undefined); const second = await loader.load('welcome', 'en-US'); expect(first?.subject).toBe('A'); expect(second?.subject).toBe('A'); }); - describe('a bundle with no en-US row at all', () => { + describe('a bundle with no en-US member at all', () => { it.each([ - ['fr-FR stored first', [FR, ZH]], - ['zh-CN stored first', [ZH, FR]], - ])('falls back to the lowest locale tag, identically in both storage orders (%s)', async (_l, rows) => { - const loader = createSysEmailTemplateLoader(fakeEngine(rows as Row[])); - expect((await loader.load('welcome', undefined))?.locale).toBe('fr-FR'); + ['fr-FR declared first', [item('fr-FR'), item('zh-CN')]], + ['zh-CN declared first', [item('zh-CN'), item('fr-FR')]], + ])('falls back to the lowest locale tag, identically in both orders (%s)', async (_l, items) => { + expect((await registryLoader(items).load('welcome', undefined))?.locale).toBe('fr-FR'); }); - it('keeps a single-locale tenant working rather than resolving to nothing', async () => { - const loader = createSysEmailTemplateLoader(fakeEngine([ZH])); - expect((await loader.load('welcome', undefined))?.locale).toBe('zh-CN'); + it('keeps a single-locale bundle working rather than resolving to nothing', async () => { + expect((await registryLoader([item('zh-CN')]).load('welcome', undefined))?.locale).toBe('zh-CN'); }); }); }); @@ -226,14 +192,14 @@ describe('EmailService.sendTemplate — locale ladder', () => { describe('sendTemplate over the real loader (the #7731 reproduction)', () => { it.each([ - ['en-US inserted first', [EN, ZH]], - ['zh-CN inserted first', [ZH, EN]], - ])('renders en-US for a no-locale send on a fresh boot (%s)', async (_l, rows) => { + ['en-US declared first', [item('en-US'), item('zh-CN')]], + ['zh-CN declared first', [item('zh-CN'), item('en-US')]], + ])('renders en-US for a no-locale send on a fresh boot (%s)', async (_l, items) => { const transport = new CaptureTransport(); const svc = new EmailService({ transport, defaultFrom: { address: 'no-reply@x.test' }, - templateLoader: createSysEmailTemplateLoader(fakeEngine(rows as Row[])), + templateLoader: registryLoader(items), }); await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', data: { name: 'Ada' } }); @@ -247,7 +213,7 @@ describe('sendTemplate over the real loader (the #7731 reproduction)', () => { const svc = new EmailService({ transport, defaultFrom: { address: 'no-reply@x.test' }, - templateLoader: createSysEmailTemplateLoader(fakeEngine([EN, ZH])), + templateLoader: registryLoader([item('en-US'), item('zh-CN')]), }); await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', locale: 'zh-CN', data: { name: 'Ada' } }); @@ -332,9 +298,12 @@ describe('sendTemplate — format filters follow the RESOLVED ROW\'s locale (#78 const svc = new EmailService({ transport, defaultFrom: { address: 'no-reply@x.test' }, - // zh-CN-only bundle: no en-US row exists, so the ladder falls through to - // the loader's own no-locale answer and lands on zh-CN. - templateLoader: createSysEmailTemplateLoader(fakeEngine([fmtRow('zh-CN')])), + // zh-CN-only bundle: no en-US member exists, so the ladder falls through + // to the loader's own no-locale answer and lands on zh-CN. + templateLoader: registryLoader([{ + name: 'receipt', label: 'Receipt', locale: 'zh-CN', + subject: fmtRow('zh-CN').subject, bodyHtml: fmtRow('zh-CN').body_html, + }]), }); await svc.sendTemplate({ template: 'receipt', to: 'a@x.test', timezone: 'UTC', data: FMT_DATA }); @@ -390,14 +359,8 @@ describe('sendTemplate — format filters follow the RESOLVED ROW\'s locale (#78 // ── the wiring: the plugin must install THIS loader ──────────────────────── describe('EmailServicePlugin wiring', () => { - it('installs the deterministic loader on kernel:ready', async () => { - const rows = [ZH, EN]; - const engine = fakeEngine(rows); + async function bootWith(services: Record) { const hooks: Record Promise | void>> = {}; - const services: Record = { - manifest: { register: () => {} }, - objectql: engine, - }; const ctx = { logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, getService: (name: string): T => { @@ -407,16 +370,34 @@ describe('EmailServicePlugin wiring', () => { registerService: (name: string, svc: unknown) => { services[name] = svc; }, hook: (name: string, fn: () => Promise | void) => { (hooks[name] ??= []).push(fn); }, }; - - const plugin = new EmailServicePlugin({ seedTemplates: false, persist: false }); + const plugin = new EmailServicePlugin({ persist: false }); await plugin.init(ctx as never); await plugin.start(ctx as never); for (const fn of hooks['kernel:ready'] ?? []) await fn(); + return (services.email as EmailService).options.templateLoader!; + } + + it('installs the registry loader on kernel:ready, reading the ObjectQL registry on a host with no metadata door', async () => { + const listed: string[] = []; + const loader = await bootWith({ + manifest: { register: () => {} }, + objectql: { registry: { listItems: (type: string) => { listed.push(type); return [item('zh-CN'), item('en-US')]; } } }, + }); + + expect((await loader.load('welcome', undefined))?.locale).toBe('en-US'); + expect(listed).toEqual(['email_template']); + }); + + it('reads the protocol\'s layered list when a metadata door is mounted — even one registered after kernel:ready', async () => { + const services: Record = { + manifest: { register: () => {} }, + objectql: { registry: { listItems: () => [item('en-US', { subject: 'package wording' })] } }, + }; + const loader = await bootWith(services); + services.protocol = { + getMetaItemsForExecution: async () => ({ items: [item('en-US', { subject: 'environment wording' })] }), + }; - const service = services.email as EmailService; - const loader = service.options.templateLoader; - expect(loader).toBeTruthy(); - expect((await loader!.load('welcome', undefined))?.locale).toBe('en-US'); - expect(engine.queries[engine.queries.length - 1]?.where).toEqual({ name: 'welcome', locale: 'en-US' }); + expect((await loader.load('welcome', 'en-US'))?.subject).toBe('environment wording'); }); }); diff --git a/packages/plugins/plugin-email/src/templates/auth-templates.ts b/packages/plugins/plugin-email/src/templates/auth-templates.ts index 6dab2a5e508..c1b705dc604 100644 --- a/packages/plugins/plugin-email/src/templates/auth-templates.ts +++ b/packages/plugins/plugin-email/src/templates/auth-templates.ts @@ -3,9 +3,11 @@ import type { EmailTemplateDefinition as EmailTemplate } from '@objectstack/spec/system'; /** - * Built-in auth email templates seeded into `sys_email_template` on - * EmailServicePlugin startup. Each template is `isSystem: true` so - * tenants may overlay subject/body but should not delete the row. + * Built-in auth email templates, code-declared as `email_template` items of + * the email plugin's own package (`EmailServicePlugin.init` registers them + * through its manifest) and resolved from the registry like any declared + * template. An app declaring the same `(name, locale)` wins over them, and a + * Studio save overrides them at environment level. * * Templates use `{{path.to.value}}` placeholders; `{{{...}}}` for * unescaped URLs (see template-engine.ts). @@ -674,11 +676,11 @@ export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES: EmailTemplate[] = [ /** * Every built-in auth template, in every supported locale (#8195). * - * ⚠️ Seeding is what makes a row *selectable*: `EmailService.sendTemplate` - * resolves `(name, locale)` out of `sys_email_template`, so a locale row that - * is exported but missing from this list resolves to nothing and the ladder - * silently falls back to the `en-US` body. Adding a locale row means adding it - * here in the same edit. + * ⚠️ Registration is what makes a member *selectable*: `EmailService.sendTemplate` + * resolves `(name, locale)` out of the registry, and only this list is + * registered, so a locale member that is exported but missing from it + * resolves to nothing and the ladder silently falls back to the `en-US` body. + * Adding a locale member means adding it here in the same edit. */ export const BUILTIN_AUTH_TEMPLATES: EmailTemplate[] = [ ...AUTH_PASSWORD_RESET_TEMPLATES, From b55b17b0591fd1adc7758e613c6a2ae894352b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:32:02 +0000 Subject: [PATCH 03/18] test(plugin-email): pin the door's release on shutdown, drop an unused import Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../plugin-shutdown-detaches-template-bridge.test.ts | 10 +++++++--- .../src/template-locale-resolution.test.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts index 154673d2bab..19bf67a39f0 100644 --- a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts +++ b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts @@ -32,6 +32,7 @@ import { describe, it, expect } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { EmailServicePlugin } from './email-plugin.js'; +import { EMAIL_TEMPLATE_DOOR_PACKAGE } from './email-template-door.js'; type AnyRecord = Record; type Hook = { event: string; handler: (ctx: AnyRecord) => unknown; packageId?: string }; @@ -51,6 +52,8 @@ function fakeEngine() { for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i].packageId === packageId) hooks.splice(i, 1); return before - hooks.length; }, + /** The door's own hooks — the plugin binds others (the outbox drain) that are not under test here. */ + doorHooks() { return hooks.filter((h) => h.packageId === EMAIL_TEMPLATE_DOOR_PACKAGE); }, /** An organization's create, as the engine runs it: every `beforeInsert` hook first. */ async organizationCreate() { for (const h of hooks.filter((x) => x.event === 'beforeInsert')) { @@ -91,12 +94,13 @@ describe('#10772 EmailServicePlugin releases what it binds on kernel shutdown', // POSITIVE CONTROL — the door really is bound, so the assertion below // measures a release and not a door that never closed. + expect(fixture.engine.doorHooks()).toHaveLength(2); await expect(fixture.engine.organizationCreate()).rejects.toThrow(/^PERMISSION_DENIED/); await kernel.shutdown(); // THE PIN. Before #10772 the kernel had no `destroy()` to call. - expect(fixture.engine.hooks).toEqual([]); + expect(fixture.engine.doorHooks()).toEqual([]); await expect(fixture.engine.organizationCreate()).resolves.toBeUndefined(); }); @@ -117,11 +121,11 @@ describe('#10772 EmailServicePlugin releases what it binds on kernel shutdown', it('the retained dispose() alias still tears down for an embedder that calls it directly', async () => { const { plugin, fixture } = await boot(); - expect(fixture.engine.hooks).not.toEqual([]); + expect(fixture.engine.doorHooks()).toHaveLength(2); await plugin.dispose(); - expect(fixture.engine.hooks).toEqual([]); + expect(fixture.engine.doorHooks()).toEqual([]); }); it('a teardown on a plugin the kernel never started is a no-op rather than a throw', async () => { diff --git a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts index be31b010330..2e4330ced65 100644 --- a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts +++ b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts @@ -13,7 +13,7 @@ // rows; its rule is the one these pinned against the row loader, unchanged. import { describe, it, expect } from 'vitest'; -import { EmailService, DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; +import { EmailService, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; import { createRegistryEmailTemplateLoader } from './template-loader.js'; import { EmailServicePlugin } from './email-plugin.js'; import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; From 279207645254e725d9ce0973274a5cc069f819c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:34:33 +0000 Subject: [PATCH 04/18] test(dogfood, showcase): pin the authored wording through the registry read, not the retired rows Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../test/email-template-locale.test.ts | 63 ++----- .../test/email-template-boot-sweep.test.ts | 163 +++++++++--------- ...l-template-materialization.dogfood.test.ts | 102 +++++++---- ...late-overlay-survives-boot.dogfood.test.ts | 128 ++++++++------ 4 files changed, 241 insertions(+), 215 deletions(-) diff --git a/examples/app-showcase/test/email-template-locale.test.ts b/examples/app-showcase/test/email-template-locale.test.ts index ff427fcc9ff..4e0d0ba68b2 100644 --- a/examples/app-showcase/test/email-template-locale.test.ts +++ b/examples/app-showcase/test/email-template-locale.test.ts @@ -13,61 +13,36 @@ // send still worked — by falling through to rung 3, the arbitrary-looking // "lowest tag in the bundle" — which is exactly why the defect was latent. // -// So these assertions run the DECLARED templates through the real boot path: -// the canonical schema parse, the real `mapTemplateToRow` projection into -// `sys_email_template` columns, and the real `createSysEmailTemplateLoader`. -// Nothing here inspects the source literal's strings; every verdict is a -// resolution. The composition of rungs 1-3 itself is plugin-email's own -// contract and is pinned there (`template-locale-resolution.test.ts`). +// So these assertions run the DECLARED templates through the real read path: +// the registry loader plugin-email resolves every send through +// (`createRegistryEmailTemplateLoader`, ADR-0131 D6/D10), over the templates +// as the manifest decomposition registers them — the declared documents +// themselves — with the canonical schema parse it applies. Nothing here +// inspects the source literal's strings; every verdict is a resolution. The +// composition of rungs 1-3 itself is plugin-email's own contract and is +// pinned there (`template-locale-resolution.test.ts`). import { describe, it, expect } from 'vitest'; import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; import { - createSysEmailTemplateLoader, - mapTemplateToRow, - EMAIL_TEMPLATE_OBJECT, + createRegistryEmailTemplateLoader, DEFAULT_TEMPLATE_LOCALE, } from '@objectstack/plugin-email'; import { allEmails, TaskDoneEmail } from '../src/system/emails/index.js'; -type Row = Record & { id: string }; - -/** What the boot seeder writes: schema parse, then the shared column mapping. */ -function materialize(templates: readonly unknown[]): Row[] { - return templates.map((t, i) => ({ - id: `row-${i}`, - ...mapTemplateToRow(EmailTemplateDefinitionSchema.parse(t) as never), - })); -} - /** - * A driver-ish engine over the materialized rows — filters by `where`, honours - * `orderBy`, then `limit`. Mirrors the fake plugin-email's own resolution - * suite uses, so the loader is exercised the way a real store exercises it. + * The registry as the manifest decomposition leaves it: each declared + * `emailTemplates:` entry registered as-is under the `email_template` type. + * The loader reads nothing else for an app's own declarations. */ -function engine(rows: Row[]) { - return { - async find(object: string, query: Record) { - expect(object).toBe(EMAIL_TEMPLATE_OBJECT); - const where = (query.where ?? {}) as Record; - let out = rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); - const orderBy = query.orderBy as Array<{ field: string; order?: string }> | undefined; - if (Array.isArray(orderBy)) { - out = [...out].sort((a, b) => { - for (const { field, order } of orderBy) { - const av = String(a[field] ?? ''); - const bv = String(b[field] ?? ''); - if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1); - } - return 0; - }); - } - return typeof query.limit === 'number' ? out.slice(0, query.limit) : out; +const loader = () => createRegistryEmailTemplateLoader({ + registry: { + listItems: (type: string) => { + expect(type).toBe('email_template'); + return allEmails.map((t) => ({ ...t, _packageId: 'com.example.showcase' })); }, - }; -} - -const loader = () => createSysEmailTemplateLoader(engine(materialize(allEmails)) as never); + }, +}); describe('showcase email templates — declared tags the send ladder can actually reach', () => { it('resolves `showcase_task_done_email` for an explicit en-US send', async () => { diff --git a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts index 833f3ea002f..e13653564de 100644 --- a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts +++ b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts @@ -1,43 +1,41 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #22062 — plugin-email's declared-template boot sweep on the REAL engine and - * the REAL SQL driver: which stored row a `(name, locale)` held by several - * organizations resolves to, and what a steady boot costs. + * The email plugin on the REAL engine and the REAL SQL driver: what a boot and + * a send cost the `sys_email_template` table — nothing, now. * - * ## Why here and not beside the sweep + * ## What this file used to pin (#22062) * - * Both answers belong to the driver: which row comes first is whatever ORDER BY - * the driver gives the read, and a statement count is a count of what reaches - * the database. `@objectstack/plugin-email` cannot import a driver for its own - * suite, so these run where `ObjectQL` and `SqlDriver` already are, against the - * sweep's published entry points. + * plugin-email's declared-template boot sweep copied every `email_template` + * into `sys_email_template` rows, and #22062 made that copy cheap: a steady + * boot over 450 unchanged templates went from 1,800 statements to three reads + * and no write, and the bulk read was paged so it chose the same row as the + * per-template lookup for a `(name, locale)` several organizations held. * - * ## What was measured before the fix (`ObjectQL` over better-sqlite3) + * ## What it pins now (ADR-0131 D6/D10, ruling C on §6 Q1) * - * - The per-template lookup, `find(…, { where: { name, locale }, limit: 1 })`, - * goes out as `… order by id asc limit ?`: the SQL driver orders every - * paged read by `id`. So its row is the smallest id, not the oldest row. - * - The same read in bulk with no `limit` gets NO ORDER BY and walks rows in - * storage order. Its first row for the slot below is `etpl_m`; the lookup's - * is `etpl_c`. A bulk read must therefore be paged too, or it picks another - * row than the lookup it replaces. The first case asserts that difference - * on the fixture, so the pin cannot drift into a fixture where both orders - * agree. - * - A steady boot over 450 unchanged templates issued 1,800 statements: per - * template one lookup, one UPDATE and the engine's two read-backs. + * The sweep, the boot seed and the live projector are gone: templates resolve + * from the registry, so the cheapest boot #22062 measured — three reads — is + * now zero statements against the table, and a send reads no row either. The + * rows earlier releases left behind — several organizations' copies of one + * slot included, the population the v18 migration ceremony promotes — keep + * their bytes and are never what a send renders. + * + * ## Why here and not beside the plugin + * + * A statement count is a count of what reaches the database, which belongs to + * the driver: `@objectstack/plugin-email` cannot import a driver for its own + * suite, so this runs where `ObjectQL` and `SqlDriver` already are. */ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysEmailTemplate } from '@objectstack/platform-objects/audit'; -import { bootstrapDeclaredEmailTemplates, upsertDeclaredEmailTemplate } from '@objectstack/plugin-email'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; const SYS = { isSystem: true, positions: [], permissions: [] }; const TABLE = 'sys_email_template'; -const NAME = 'auth.password_reset'; -const DECLARED_SUBJECT = 'Reset your password, {{user.name}}'; const engines: ObjectQL[] = []; @@ -46,7 +44,7 @@ afterEach(async () => { }); /** A fresh engine on its own `:memory:` database, every statement it sends recorded. */ -async function boot(): Promise<{ engine: ObjectQL; statements: string[] }> { +async function bootEngine(): Promise<{ engine: ObjectQL; statements: string[] }> { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); const engine = new ObjectQL(); engine.registerDriver(driver, true); @@ -66,26 +64,46 @@ async function boot(): Promise<{ engine: ObjectQL; statements: string[] }> { return { engine, statements }; } -function declared(over: Record = {}) { - return { - name: NAME, - label: 'Password Reset', - category: 'auth', - subject: DECLARED_SUBJECT, - bodyHtml: '

Click here

', - variables: [{ name: 'url', type: 'string', required: true }], - ...over, +/** + * Boot `EmailServicePlugin` to `kernel:ready` over `engine`, its manifest + * registered through the engine's own `registerApp`, and hand back the + * `email` service it installs. Sends are captured off the wire. + */ +async function bootEmail(engine: ObjectQL) { + const sent: Array<{ subject?: string }> = []; + const services: Record = { + manifest: { register: (m: unknown) => engine.registerApp(m as never) }, + objectql: engine, }; + const hooks: Record Promise | void>> = {}; + const ctx = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`service '${name}' not registered`); + return services[name] as T; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + hook: (name: string, fn: () => Promise | void) => { (hooks[name] ??= []).push(fn); }, + }; + const plugin = new EmailServicePlugin({ + persist: false, + transport: { async send(m: { subject?: string }) { sent.push({ subject: m.subject }); return { messageId: `m${sent.length}` }; } } as never, + defaultFrom: { address: 'no-reply@example.test' }, + }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { email: services.email as any, sent, plugin }; } -/** The sweep's declared list, through the metadata-service fallback it reads when the registry holds none. */ -const listing = (items: unknown[]) => ({ list: () => items }); +/** Statements that name the template table. */ +const onTable = (statements: string[]) => statements.filter((s) => s.includes(`\`${TABLE}\``)); /** * One `(name, locale)` held by three organizations and the global row, plus - * another locale of the same name. Stored in an order that is neither id order - * nor its reverse, every row stale, so whichever row the sweep picks is the one - * it rewrites. + * another locale of the same name: the shape #22062 pinned the row choice on, + * every row carrying stale wording a send must never render. */ async function seedSharedSlot(engine: ObjectQL): Promise { const rows: Array<[string, string | null, string]> = [ @@ -97,64 +115,45 @@ async function seedSharedSlot(engine: ObjectQL): Promise { ]; for (const [id, organization_id, locale] of rows) { await engine.insert(TABLE, { - id, organization_id, name: NAME, label: 'Stale', category: 'auth', locale, - subject: `stale ${id}`, body_html: '

stale

', active: true, managed_by: 'package', customized: false, + id, organization_id, name: 'auth.password_reset', label: 'Stale', category: 'auth', locale, + subject: `stale ${id}`, body_html: '

stale

', active: true, managed_by: 'package', customized: id === 'etpl_c', }, { context: SYS } as never); } } async function subjects(engine: ObjectQL): Promise> { - const rows = (await engine.find(TABLE, { where: { name: NAME }, context: SYS } as never)) as any[]; + const rows = (await engine.find(TABLE, { context: SYS } as never)) as any[]; return Object.fromEntries(rows.map((r) => [r.id, r.subject])); } -describe('[#22062] the declared-template boot sweep on the real engine and SQL driver', () => { - it('rewrites the row the per-template lookup chooses for a (name, locale) several organizations hold', async () => { - const { engine, statements } = await boot(); - await seedSharedSlot(engine); +const RESET_DATA = { appName: 'Acme', resetUrl: 'https://example.test/r', user: { name: 'Ada', email: 'ada@example.test' } }; - // The per-template lookup's choice, measured live rather than assumed. +describe('the email plugin on the real engine and SQL driver: no statement reaches sys_email_template', () => { + it('a boot and a send of a built-in template send no statement to the table', async () => { + const { engine, statements } = await bootEngine(); statements.length = 0; - const [chosen] = (await engine.find(TABLE, { where: { name: NAME, locale: 'en-US' }, limit: 1, context: SYS } as never)) as any[]; - expect(statements).toEqual(['select * from `sys_email_template` where `name` = ? and `locale` = ? order by `id` asc limit ?']); - expect(chosen.id).toBe('etpl_c'); - // ANTI-VACUITY: an unpaged bulk read meets another row of this slot first. - const unpaged = (await engine.find(TABLE, { where: { name: { $in: [NAME] } }, context: SYS } as never)) as any[]; - expect(unpaged.find((r) => r.locale === 'en-US').id).not.toBe(chosen.id); - - const result = await bootstrapDeclaredEmailTemplates(engine as never, listing([declared()])); - - expect(result).toEqual({ seeded: 1, skipped: 0 }); - expect(await subjects(engine)).toEqual({ - etpl_c: DECLARED_SUBJECT, - etpl_m: 'stale etpl_m', - etpl_z: 'stale etpl_z', - etpl_q: 'stale etpl_q', - etpl_0: 'stale etpl_0', - }); - }); - - it('the live door rewrites the same row over the same rows', async () => { - const { engine } = await boot(); - await seedSharedSlot(engine); - expect(await upsertDeclaredEmailTemplate(engine as never, declared())).toBe(true); + const { email, sent } = await bootEmail(engine); + await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', data: RESET_DATA }); - expect((await subjects(engine)).etpl_c).toBe(DECLARED_SUBJECT); - expect(Object.values(await subjects(engine)).filter((s) => s === DECLARED_SUBJECT)).toHaveLength(1); + expect(sent.map((m) => m.subject)).toEqual(['Reset your Acme password']); + expect(onTable(statements)).toEqual([]); + expect(await subjects(engine)).toEqual({}); }); - it('a steady boot over 450 unchanged templates sends three reads and no write', async () => { - const { engine, statements } = await boot(); - const items = Array.from({ length: 450 }, (_, i) => declared({ name: `tpl.n${i}`, bodyText: i % 3 ? undefined : 'Plain' })); - expect(await bootstrapDeclaredEmailTemplates(engine as never, listing(items))).toEqual({ seeded: 450, skipped: 0 }); - + it('rows several organizations hold for one slot keep their bytes and are never what a send renders', async () => { + const { engine, statements } = await bootEngine(); + await seedSharedSlot(engine); + const before = await subjects(engine); statements.length = 0; - const result = await bootstrapDeclaredEmailTemplates(engine as never, listing(items)); - expect(result).toEqual({ seeded: 0, skipped: 450 }); - expect(statements.filter((s) => !/^select /i.test(s))).toEqual([]); - expect(statements).toHaveLength(3); - expect(statements.every((s) => /^select \* from `sys_email_template` where `name` in \(.*\) order by `id` asc limit \?$/.test(s))).toBe(true); + const { email, sent } = await bootEmail(engine); + await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', data: RESET_DATA }); + await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', locale: 'zh-CN', data: RESET_DATA }); + + expect(sent.map((m) => m.subject)).toEqual(['Reset your Acme password', '重置您的 Acme 密码']); + expect(onTable(statements)).toEqual([]); + expect(await subjects(engine)).toEqual(before); + expect(Object.keys(before)).toHaveLength(5); }); }); diff --git a/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts index 8443002ec26..0114daf74ff 100644 --- a/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts @@ -9,26 +9,34 @@ // (packages/spec/liveness/email_template.json); the spec liveness gate fails if // this tag is removed. See proof-registry.mts. // -// An email_template prop being `live` means the materializer + renderer READ it -// — necessary but not sufficient. This boots the real stack with the email +// An email_template prop being `live` means the loader + renderer READ it — +// necessary but not sufficient. This boots the real stack with the email // plugin, authors a template via the app config's `emailTemplates:` array, and -// asserts BOTH ends of the graph that #4509 found disconnected: -// 1. the authored metadata materializes into a `sys_email_template` row with -// the spec→runtime remap applied (`bodyHtml`→`body_html`); -// 2. `EmailService.sendTemplate` then renders THAT template — not the built-in -// auth copy it overrides. This is the ADR-0078 false-compliance case the -// issue named: an admin "fixes" the password-reset mail, gets a success -// toast, and users keep receiving the built-in version. +// asserts that the AUTHORED wording is what reaches the transport — not the +// built-in auth copy it overrides. That is the ADR-0078 false-compliance case +// #4509 named: an admin "fixes" the password-reset mail, gets a success toast, +// and users keep receiving the built-in version. // -// It also pins the #3461 integration lesson the bridge inherits: materialization -// is gated on the data engine ALONE, so a boot with no transport configured -// (LogTransport) must still materialize — a bridge re-gated behind delivery -// prerequisites would materialize nothing and this proof would fail. +// ADR-0131 D6/D10 (ruling C on §6 Q1) moved the seam this proof crosses. The +// authored template used to be materialized into a `sys_email_template` row +// that `sendTemplate` read; it is now read from the registry by `(name, +// locale)`, where the built-in auth templates are code-declared too. So the +// proof asserts both ends of the new graph: +// 1. no row is materialized at all, and the registry serves the authored +// item beside the built-in one it overrides; +// 2. `EmailService.sendTemplate` renders THAT item — and still renders the +// built-in where the app declares no member (the zh-CN send); +// 3. a Studio save over the authored template sends on the very next mail, +// with no restart. +// +// It also pins the #3461 integration lesson: templates resolve on the data +// engine alone, so a boot with no real transport configured must still serve +// them. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { EmailServicePlugin } from '@objectstack/plugin-email'; -import { emailTemplateFixtureStack } from './fixtures/email-template-materialization-fixture.js'; +import { emailTemplateFixtureStack, etPasswordReset } from './fixtures/email-template-materialization-fixture.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; @@ -63,29 +71,24 @@ describe('objectstack verify EMAIL TEMPLATE: authored template materializes and await stack?.stop(); }); - it('materializes the stack-authored template into a sys_email_template row (bodyHtml→body_html)', async () => { + it('materializes no row: the registry serves the authored item beside the built-in it overrides', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const engine = (await stack.kernel.getServiceAsync('objectql')) as any; - const rows = await engine.find('sys_email_template', { - filter: { name: 'auth.password_reset', locale: 'en-US' }, - context: SYSTEM_CTX, - }); - - expect(rows, 'authored template was NOT materialized into a sys_email_template row').toHaveLength(1); - const row = rows[0]; - - // The remap that is the whole point of the bridge, and the authored wording - // beating the built-in seed. - expect(row.body_html, 'spec `bodyHtml` did not remap to runtime `body_html`') - .toContain('Authored body'); - expect(row.subject, 'the built-in auth template overwrote the authored one') - .toBe('Authored reset for {{user.name}}'); + expect(await engine.find('sys_email_template', { context: SYSTEM_CTX })).toEqual([]); - // Boot-seeded provenance (seed-not-clobber): a package row, not admin. - expect(row.managed_by).toBe('package'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const protocol = (await stack.kernel.getServiceAsync('protocol')) as any; + const listed = await protocol.getMetaItemsForExecution({ type: 'email_template' }); + const slot = (listed.items as Array>) + .filter((i) => i.name === 'auth.password_reset' && (i.locale ?? 'en-US') === 'en-US') + .map((i) => ({ packageId: i._packageId, subject: i.subject })); + expect(slot).toEqual(expect.arrayContaining([ + { packageId: 'com.dogfood.email-template-fixture', subject: 'Authored reset for {{user.name}}' }, + { packageId: 'com.objectstack.service.email', subject: 'Reset your {{appName}} password' }, + ])); }); - it('renders the authored template through sendTemplate — the disconnect #4509 closed', async () => { + it('renders the authored template through sendTemplate — the built-in copy does not reach the wire', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const email = (await stack.kernel.getServiceAsync('email')) as any; @@ -103,4 +106,39 @@ describe('objectstack verify EMAIL TEMPLATE: authored template materializes and .toBe('Authored reset for Ada'); expect(delivered?.html).toContain('Authored body'); }); + + it('still renders the built-in member where the app declares none (the zh-CN send)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const email = (await stack.kernel.getServiceAsync('email')) as any; + + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: 'auth.password_reset', + locale: 'zh-CN', + data: { appName: 'Acme', resetUrl: 'https://example.test/reset', expiresInMinutes: 30, user: { name: 'Ada', email: 'ada@example.test' } }, + }); + + expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); + expect(sent.at(-1)?.subject).toBe('重置您的 Acme 密码'); + }); + + it('a Studio save over the authored template sends on the very next mail, with no restart', async () => { + const token = await stack.signIn(); + const put = await stack.apiAs(token, 'PUT', '/meta/email_template/auth.password_reset', { + ...etPasswordReset, + subject: 'Studio reset for {{user.name}}', + }); + expect(put.status, await put.clone().text()).toBe(200); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const email = (await stack.kernel.getServiceAsync('email')) as any; + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: 'auth.password_reset', + data: { url: 'https://example.test/reset', user: { name: 'Ada' } }, + }); + + expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); + expect(sent.at(-1)?.subject).toBe('Studio reset for Ada'); + }); }); diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts index 104dec92a90..2661441c608 100644 --- a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -1,46 +1,48 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // [#21785] An email template edited through the METADATA door keeps the -// admin's wording in the row the mailer sends from, across a cold boot on one -// database file — over the real showcase composition. +// admin's wording in the mail it sends, across a cold boot on one database +// file — over the real showcase composition. // // ## What was broken // // `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) stores an -// overlay, and the live projector writes its wording into `sys_email_template` +// overlay, and the live projector wrote its wording into `sys_email_template` // at once. On the next boot the declared-template sweep re-projected the // PACKAGE wording over that row, while `GET /meta` kept serving the admin's — -// the metadata the admin sees and the row the mailer sends diverged silently. -// Measured on `origin/main` before the fix, through this file's own steps: the -// row read `✅ Task done: {{title}}` after the restart. +// the metadata the admin sees and the row the mailer sent from diverged +// silently. Measured on `origin/main` before that fix, through this file's +// own steps: the row read `✅ Task done: {{title}}` after the restart. // -// ## Why `orgContext: true` +// ## What changed under it (ADR-0131 D6/D10, ruling C on §6 Q1) +// +// The row is gone from the send path. The mailer reads the template from the +// registry — the protocol's layered list, the same item `GET /meta` serves — +// at every send, so there is no second copy left to diverge, nothing to +// project and nothing to re-project at boot. The pins below hold the same +// outcome #21785 asked for, through the read that replaced the row, and add +// what the replacement owes: the save is live on the very next send, and the +// boot writes no `sys_email_template` row. // -// It is the shape the defect lives in. A real deployment bootstraps the -// Default Organization (`autoDefaultOrganization` is plugin-auth's default), -// so the admin's save lands as an ORG-SCOPED overlay, which boot hydration -// leaves out of the registry the sweep used to read. The harness default is an -// org-less admin, whose save lands env-wide — and that shape kept the admin's -// wording before the fix as well (measured), so a pin booted that way would -// pass against the defect. +// ## Why `orgContext: true` // -// [ADR-0131 D6] Since the per-organization overlay axis retired, the metadata -// door carries no organization, so even this org-active admin's save lands -// ENVIRONMENT-WIDE, and the boot sweep reads the environment layer (it names -// no organization either). `orgContext: true` stays: it is the deployment -// shape, and the first case now pins that the save is environment-wide there. +// It is the deployment shape. A real deployment bootstraps the Default +// Organization (`autoDefaultOrganization` is plugin-auth's default), and +// before the per-organization overlay axis retired an org-active admin's save +// landed ORG-SCOPED. [ADR-0131 D6] The metadata door now carries no +// organization, so even this admin's save lands ENVIRONMENT-WIDE, and the +// first case pins that it does. // // ## What each case pins // -// - the metadata-door edit is environment-wide and projected at once (preconditions); -// - after a cold boot the sending row, `GET /meta` and a real `sendTemplate` -// all carry the admin's wording; +// - the metadata-door edit is environment-wide and sends at once, with no +// restart, and no `sys_email_template` row exists (the boot seeds none); +// - after a cold boot `GET /meta` and a real `sendTemplate` both carry the +// admin's wording; // - control: the organization DATA door is closed (ADR-0131 D6, ruling C on // §6 Q1) — an edit and a create through `/data/sys_email_template` are -// refused 403 `PERMISSION_DENIED`, nothing is marked `customized`, and the -// metadata-door wording still survives the next cold boot. Before the door -// closed this case pinned the opposite: a data-door edit stamped -// `customized: true` that survived the boot. +// refused 403 `PERMISSION_DENIED`, nothing is written, and the +// metadata-door wording still sends after the next cold boot. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; @@ -59,7 +61,6 @@ const SYS = { isSystem: true, positions: [], permissions: [] }; const NAME = 'showcase_task_done_email'; const PACKAGE_SUBJECT = '✅ Task done: {{title}}'; const ADMIN_SUBJECT = 'Done (reworded by the admin): {{title}}'; -const DATA_DOOR_SUBJECT = 'Done (edited on the record): {{title}}'; /** What reached the wire — `sendTemplate` returns a delivery result, not the rendered message. */ const sent: Array<{ subject?: string }> = []; @@ -82,10 +83,21 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho const res = await stack!.apiAs(token, method, path, body); return { status: res.status, json: (await res.json().catch(() => ({}))) as any }; }; - /** The sending rows for the template — what `sendTemplate` resolves from. */ - const sendingRows = async () => { + /** Every `sys_email_template` row — the table the mailer no longer reads or writes. */ + const templateRows = async () => { const ql: any = await stack!.kernel.getServiceAsync('objectql'); - return ql.find('sys_email_template', { where: { name: NAME }, context: SYS }); + return ql.find('sys_email_template', { context: SYS }); + }; + /** A real send of the template; the subject is read off the wire. */ + const sendSubject = async () => { + const email: any = await stack!.kernel.getServiceAsync('email'); + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: NAME, + data: { title: 'Ship it', project: 'Apollo' }, + }); + expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); + return sent.at(-1)?.subject; }; /** `GET /meta/email_template/:name` — the served document, unwrapped from its envelope. */ const metaSubject = async () => { @@ -114,10 +126,11 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho if (dir) rmSync(dir, { recursive: true, force: true }); }); - it('precondition: an org-active admin\'s metadata-door edit lands environment-wide and is projected into the sending row at once', async () => { - const [seeded] = await sendingRows(); - expect({ subject: seeded?.subject, managed_by: seeded?.managed_by, customized: seeded?.customized }) - .toEqual({ subject: PACKAGE_SUBJECT, managed_by: 'package', customized: false }); + it('an org-active admin\'s metadata-door edit lands environment-wide and sends at once, with no row behind it', async () => { + // The boot seeded no row: the built-ins and the showcase's declaration + // resolve from the registry. + expect(await templateRows()).toEqual([]); + expect(await sendSubject()).toBe('✅ Task done: Ship it'); const served = await call('GET', `/meta/email_template/${NAME}`); expect(served.status).toBe(200); @@ -126,9 +139,9 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho for (const [k, v] of Object.entries((doc?.item ?? doc) as Record)) { if (!k.startsWith('_')) body[k] = v; } + expect(body.subject).toBe(PACKAGE_SUBJECT); const put = await call('PUT', `/meta/email_template/${NAME}`, { ...body, subject: ADMIN_SUBJECT }); expect(put.status, JSON.stringify(put.json)).toBe(200); - expect(put.json?.projectionApplied).toEqual({ success: true }); // [ADR-0131 D6] The door names no organization, whatever the admin's. const ql: any = await stack!.kernel.getServiceAsync('objectql'); @@ -136,31 +149,31 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho expect(stored).toHaveLength(1); expect(stored[0].organization_id ?? null, 'the overlay is environment-wide').toBeNull(); - expect((await sendingRows()).map((r: any) => r.subject)).toEqual([ADMIN_SUBJECT]); + // Live: the very next send, no restart, no projection. + expect(await sendSubject()).toBe('Done (reworded by the admin): Ship it'); + expect(await templateRows()).toEqual([]); }); - it('after a cold boot the sending row, the metadata door and the rendered mail all carry the admin\'s wording', async () => { + it('after a cold boot the metadata door and the rendered mail both carry the admin\'s wording', async () => { await restart(); - const rows = await sendingRows(); - expect(rows.map((r: any) => ({ subject: r.subject, customized: r.customized }))) - .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); expect(await metaSubject()).toEqual({ status: 200, subject: ADMIN_SUBJECT }); - - const email: any = await stack!.kernel.getServiceAsync('email'); - const result = await email.sendTemplate({ - to: 'someone@example.com', - template: NAME, - data: { title: 'Ship it', project: 'Apollo' }, - }); - expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); - expect(sent.at(-1)?.subject).toBe('Done (reworded by the admin): Ship it'); + expect(await sendSubject()).toBe('Done (reworded by the admin): Ship it'); + expect(await templateRows()).toEqual([]); }, 180_000); - it('control: the organization data door is closed — an edit and a create are refused, and the metadata-door wording survives the next cold boot', async () => { + it('control: the organization data door is closed — an edit and a create are refused, and the metadata-door wording still sends after the next cold boot', async () => { const closedDoor = /^PERMISSION_DENIED: sys_email_template is closed to organization writes/; - const [row] = await sendingRows(); - const patched = await call('PATCH', `/data/sys_email_template/${row.id}`, { subject: DATA_DOOR_SUBJECT }); + // A row an earlier release left in the table — the legacy population + // the v18 migration ceremony promotes. Written in system context, the + // one context the door lets through. + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + await ql.insert('sys_email_template', { + id: 'etpl_legacy', name: NAME, label: 'Task done', category: 'custom', locale: 'en-US', + subject: 'Legacy row wording', body_html: '

legacy

', active: true, + managed_by: 'package', customized: true, + }, { context: SYS }); + const patched = await call('PATCH', '/data/sys_email_template/etpl_legacy', { subject: 'Edited on the record' }); expect(patched.status, JSON.stringify(patched.json)).toBe(403); expect(patched.json?.code).toBe('PERMISSION_DENIED'); expect(String(patched.json?.error)).toMatch(closedDoor); @@ -173,14 +186,15 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho expect(created.json?.code).toBe('PERMISSION_DENIED'); expect(String(created.json?.error)).toMatch(closedDoor); - const ql: any = await stack!.kernel.getServiceAsync('objectql'); expect(await ql.find('sys_email_template', { where: { name: 'org_owned_template' }, context: SYS })).toEqual([]); - expect((await sendingRows()).map((r: any) => ({ subject: r.subject, customized: r.customized }))) - .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); + // The legacy row keeps its bytes, and it is not what sends. + const legacy = () => templateRows().then((rows: any[]) => rows.map((r) => ({ id: r.id, subject: r.subject, customized: r.customized }))); + expect(await legacy()).toEqual([{ id: 'etpl_legacy', subject: 'Legacy row wording', customized: true }]); + expect(await sendSubject()).toBe('Done (reworded by the admin): Ship it'); await restart(); - expect((await sendingRows()).map((r: any) => ({ subject: r.subject, customized: r.customized }))) - .toEqual([{ subject: ADMIN_SUBJECT, customized: false }]); + expect(await legacy()).toEqual([{ id: 'etpl_legacy', subject: 'Legacy row wording', customized: true }]); + expect(await sendSubject()).toBe('Done (reworded by the admin): Ship it'); }, 180_000); }); From b0fbd2b474cd77b477080ebec676d4f1728f9a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:36:46 +0000 Subject: [PATCH 05/18] test(dogfood): boot the plugin's own manifest in the statement-count pin Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../test/email-template-boot-sweep.test.ts | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts index e13653564de..2d507ae5b35 100644 --- a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts +++ b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts @@ -31,7 +31,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysEmailTemplate } from '@objectstack/platform-objects/audit'; import { EmailServicePlugin } from '@objectstack/plugin-email'; const SYS = { isSystem: true, positions: [], permissions: [] }; @@ -43,33 +42,21 @@ afterEach(async () => { while (engines.length) await engines.pop()?.destroy(); }); -/** A fresh engine on its own `:memory:` database, every statement it sends recorded. */ -async function bootEngine(): Promise<{ engine: ObjectQL; statements: string[] }> { +/** + * A fresh engine on its own `:memory:` database with `EmailServicePlugin`'s + * `init()` run against it — the plugin's manifest (its two objects and the + * built-in templates) registered through the engine's own `registerApp`, the + * schema synced — and every statement the engine sends from then on recorded. + * `start()` takes the plugin through `kernel:ready` and hands back the `email` + * service it installs; sends are captured off the wire. + */ +async function boot() { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); const engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - engine.registerApp({ - id: 'com.objectstack.dogfood.email-template-boot-sweep', - name: 'Email template boot sweep', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysEmailTemplate], - } as never); - await engine.syncSchemas(); engines.push(engine); - const statements: string[] = []; - driver.getKnex().on('query', (q: { sql: string }) => statements.push(q.sql)); - return { engine, statements }; -} -/** - * Boot `EmailServicePlugin` to `kernel:ready` over `engine`, its manifest - * registered through the engine's own `registerApp`, and hand back the - * `email` service it installs. Sends are captured off the wire. - */ -async function bootEmail(engine: ObjectQL) { const sent: Array<{ subject?: string }> = []; const services: Record = { manifest: { register: (m: unknown) => engine.registerApp(m as never) }, @@ -91,10 +78,17 @@ async function bootEmail(engine: ObjectQL) { defaultFrom: { address: 'no-reply@example.test' }, }); await plugin.init(ctx as never); - await plugin.start(ctx as never); - for (const fn of hooks['kernel:ready'] ?? []) await fn(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { email: services.email as any, sent, plugin }; + await engine.syncSchemas(); + + const statements: string[] = []; + driver.getKnex().on('query', (q: { sql: string }) => statements.push(q.sql)); + const start = async () => { + await plugin.start(ctx as never); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return services.email as any; + }; + return { engine, statements, sent, start }; } /** Statements that name the template table. */ @@ -130,10 +124,10 @@ const RESET_DATA = { appName: 'Acme', resetUrl: 'https://example.test/r', user: describe('the email plugin on the real engine and SQL driver: no statement reaches sys_email_template', () => { it('a boot and a send of a built-in template send no statement to the table', async () => { - const { engine, statements } = await bootEngine(); + const { engine, statements, sent, start } = await boot(); statements.length = 0; - const { email, sent } = await bootEmail(engine); + const email = await start(); await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', data: RESET_DATA }); expect(sent.map((m) => m.subject)).toEqual(['Reset your Acme password']); @@ -142,12 +136,12 @@ describe('the email plugin on the real engine and SQL driver: no statement reach }); it('rows several organizations hold for one slot keep their bytes and are never what a send renders', async () => { - const { engine, statements } = await bootEngine(); + const { engine, statements, sent, start } = await boot(); await seedSharedSlot(engine); const before = await subjects(engine); statements.length = 0; - const { email, sent } = await bootEmail(engine); + const email = await start(); await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', data: RESET_DATA }); await email.sendTemplate({ template: 'auth.password_reset', to: 'ada@example.test', locale: 'zh-CN', data: RESET_DATA }); From b12766587a8addd5448e61ba9c184f7f7e04809e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:45:48 +0000 Subject: [PATCH 06/18] docs(spec, ledger): re-anchor the email_template liveness ledger to the registry loader; describe text names the registry, not sys_email_template rows The ledger is hand-written measurement, re-anchored by hand: every live key now cites template-loader.ts#toRenderInput / #createRegistryEmailTemplateLoader and the service's reads. category and isSystem re-grade live -> dead (their only consumer was the retired row projection); label and description stay live on the Studio metadata list (objectui pin 0df67f23). No schema shape change. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/audit/sys-email-template.object.ts | 51 +++++----- .../src/builtin/notify-node.ts | 2 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/email_template.json | 93 ++++++++++--------- .../liveness/state-counts/email_template.md | 2 +- .../spec/src/automation/io-node-config.zod.ts | 16 ++-- packages/spec/src/contracts/email-service.ts | 44 ++++----- .../spec/src/system/email-template.zod.ts | 10 +- packages/spec/src/system/notification.zod.ts | 4 +- 9 files changed, 118 insertions(+), 106 deletions(-) diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index 2ea2fa6fece..f3809d9378a 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -3,25 +3,24 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_email_template — Outbound Email Template (metadata row) + * sys_email_template — Outbound Email Template (legacy rows) * - * Backing persistence for the `email_template` metadata type. Each - * row is the runtime representation of an `EmailTemplate` Zod - * envelope. Resolved by an EXACT `(name, locale)` match — the - * EmailService does NO language-subtag folding, so `en-US` never - * reaches an `en` row and there is no "best match". A call that NAMES - * a locale retries exactly one rung, the literal `en-US`, and then - * raises `TEMPLATE_NOT_FOUND`; a call that names none starts AT - * `en-US` and, only when the bundle carries no `en-US` row at all, - * takes the bundle's lowest locale tag. See - * `@objectstack/plugin-email`'s `createSysEmailTemplateLoader` and - * `SendTemplateInput.locale` for the ladder of record. + * No mail is sent from this table any more (ADR-0131 D6/D10, ruling C on + * §6 Q1). Email templates are `email_template` metadata, read from the + * registry by an EXACT `(name, locale)` match: the built-in auth templates + * code-declared by `@objectstack/plugin-email`, the ones an app declares, and + * the ones authored in Studio (environment level). See plugin-email's + * `createRegistryEmailTemplateLoader` and `SendTemplateInput.locale` for the + * ladder of record. * - * Authoring: built-in templates are seeded by `EmailServicePlugin` - * on `kernel:ready`, and an `email_template` edited in Studio is - * projected into its row. An organization does not create or edit a - * row here: `@objectstack/plugin-email` refuses a non-system create or - * update (ADR-0131 D6). + * What the table still holds is the rows earlier releases wrote — the + * built-in seed, the declared-template sweep and the Studio projection — + * including rows an organization customized before its door closed. Those + * customized rows are the population the v18 migration ceremony promotes to + * environment-level Studio templates; nothing else reads, writes or deletes + * them. An organization does not create or edit a row here: + * `@objectstack/plugin-email` refuses a non-system create or update + * (ADR-0131 D6). * * @namespace sys */ @@ -155,15 +154,15 @@ export const SysEmailTemplate = ObjectSchema.create({ }), // ── Provenance (#4509 — record-authoritative seed-not-clobber) ── - // Mirrors sys_webhook (#3461) / sys_sharing_rule (#2909). `is_system` - // remains the built-in-auth-template axis; these two track the DECLARED - // metadata door: bootstrapDeclaredEmailTemplates seeds `package` rows and - // re-seeds them every boot, and skips a row marked `customized`. Both are - // `readonly` — the engine strips them from non-system payloads. Since - // ADR-0131 ruling C closed the organization door (plugin-email refuses a - // non-system create or update), nothing marks a row `customized` any more: - // the rows already marked are the population the v18 migration ceremony - // promotes to environment-level Studio templates. + // Mirrors sys_webhook (#3461) / sys_sharing_rule (#2909). Recorded on the + // rows earlier releases wrote: `is_system` was the built-in seed's axis, + // and these two tracked the DECLARED metadata door — `package` rows the + // boot sweep re-seeded, and the `customized` mark it skipped. Both are + // `readonly` — the engine strips them from non-system payloads. Nothing + // writes either any more: templates resolve from the registry, and + // ADR-0131 ruling C closed the organization door. The rows already marked + // `customized` are the population the v18 migration ceremony promotes to + // environment-level Studio templates. managed_by: Field.select( ['platform', 'package', 'admin'], { diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 812c38ad6cc..5a172a0a42c 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -207,7 +207,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // both recorded here, neither in the help text. template: { type: 'string', - description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own sys_user.locale when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node\'s payload.locale is not consulted. Mutually exclusive with inline title/message.', + description: 'Email template name (the email template\'s name) — the localizable content path: resolved by (name, locale) at delivery time among the email templates (built-in, declared by an app, or authored in Studio), rendering subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient\'s own sys_user.locale when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node\'s payload.locale is not consulted. Mutually exclusive with inline title/message.', }, templateData: { type: 'object', diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 08d61a088b1..a70e42404ca 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -976,7 +976,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | app | seeded 2026-08-01 (#4488). Dead 8 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (#4667 tombstone — the landing IS the first nav item; root landing follows `isDefault` routing). `areas.description` left that set on 2026-09-30 (#20299): the Studio app preview draws it (objectui#11027). RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 **#4651**: the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — this ledger's most important app finding — are REMOVED, rows DELETED (strict removal; retained rows would report ORPHAN). They were not merely unread: `filterAppForUser` never reads `item.areas` at all and the shell renders every area, so a "hidden" or permission-gated area was served to everyone, while the identically named per-ITEM and per-APP keys ARE enforced. Route B (remove) over route A (enforce): enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold. Boundary unchanged and still recorded on `areas.navigation`: per-item gating inside an area is shell-side only. **#4667**: `homePageId` TOMBSTONED (row stays — retiredKey keeps it in the walked shape) and `areas.order` row DELETED (strict removal); `areas.order` read alive because the per-ITEM `order` really is sorted (NavigationRenderer.tsx:1154) while no renderer ever sorted areas. **The dead eight above reconcile exactly** — only `areas.description` has left that set since #4667, and nothing has joined it; what moved after it are two additions on the other side of the ledger. **#4829** (PR #6942) declares `_unpublished`, `live`: a MACHINE-MANAGED publish gate, never authored, written by the AI additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and server-enforced in `filterAppForUser`. It is declared on AppSchema rather than omitted because the write path validates against that schema, so the flip itself would otherwise be unwritable — a `live` row for a key no author may set, which is the inverse of every dead row here. **#4848** (PR #7253) gave this type its **first `planned`**: `navigation.runAction`, the contract-first half of the SDUI deep-link promotion. `planned` and not `dead` was load-bearing, the same distinction `api`'s two mapping transforms draw — the reference IS validated at authoring (framework `validateCrossReferences`, the lint's nav `runAction` arm), and it carried `authorWarn` saying the shell did not read the declared slot yet, while auto-run fired only via the transitional `?runAction=` query param. **#10068 FLIPS it `live` 2026-08-20** (objectui#5216 via objectui PR #5354, absorbed by the `.objectui-sha` pin `9a3daf8`, which postdates that merge — both pointers were read AT THE PIN this repo builds against). It is this table's clearest worked example of **why one pointer is not a call graph**: the work-list anticipated a single `NavigationRenderer` pointer, and that would have overstated one half and understated the other. The renderer is the **`producer`** (`NAV_RUN_ACTION_PARAM`, the wire name's one definition, and `withRunAction` applied in `resolveHref`'s object branch — list landings only, never the `recordId` branch): it WRITES the deep link and runs nothing. What makes authoring the key change runtime behaviour is the **`evidence`** side, `app-shell`'s `useNavRunAction` read-once/consume-once hook, wired at every object list plus the entitlement-gated environment toolbar — so a renderer-only pointer would have said the slot is live because something *emits* it. Read it beside `seed.env` in the `producer` section above: same shape, opposite outcome, because here the second input really is supplied. ⚠️ It also records an **enforcement ≠ consumption** measurement worth carrying: the published `@objectstack/spec@17.0.0` does not enforce the `runAction` × `recordId` exclusivity (the `objectNavTargetExclusivity` superRefine is on this repo's `main` but outside the GA build) and accepts `runAction: ''` — the merged-but-unpublished window, not a defect — which makes objectui's list-surface-only precedence load-bearing rather than defensive. **merged upstream ≠ published ≠ pinned downstream**, and unlike a missing key a missing *refinement* fails silent (objectui#5328). | | book | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant is live — `tags` was DECLARED on DocSchema in 17.0.0 (#4509, ADR-0049); see the `doc` row below for the fix's full history. **#4667**: both inline translation maps retired — book-level row DELETED (BookSchema is strictObject), group-level row KEPT as a tombstone (BookGroupSchema is a plain z.object with no .strict(), so a bare delete would have zod silently strip it). No resolver read either; the trap was proximity to `doc.translations`, which is live on every doc render path. | | doc | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type. **`tags` DECLARED in 17.0.0 (#4509)** — the enforce half of enforce-or-remove: the book resolver's `include: { tag }` matcher, the REST transport and `ResolverDoc.tags` all already existed, but DocSchema is strict and had no `tags` key, so authoring one was a parse error and the variant could never match. Live on arrival | -| email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | +| email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) **#15205** (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the row path above RETIRED — the built-in seed, `bootstrapDeclaredEmailTemplates`, `mapTemplateToRow` and the live projector are gone, and `createRegistryEmailTemplateLoader` reads `email_template` from the registry by (name, locale) at every send, the built-in auth templates code-declared under the email plugin's own package. Every live citation was re-anchored by hand to the loader's `toRenderInput` and the service's reads; the proof now asserts the authored override, the built-in fallback locale and a Studio save live without a restart. Two keys re-grade `live` → `dead`, because the row projection was their only consumer: `category` (a Setup column; objectui reads it only as an editor input, never as the list facet the schema promises) and `isSystem` (the built-in seeder's stand-down). `label` and `description` stay `live` on the Studio metadata list's default columns (objectui pin 0df67f23). ⚠️ `category` is schema-DEFAULTED, so its dead verdict warns on every template — the unwarnable-default shape the `mapping` row retired keys over | | job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | | mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape. `connectorSource` (2026-09-30) is this type's first `planned` row: the pull binding the connector-attached sync family MOVED here when the connector's `syncConfig` / `fieldMappings` retired to tombstones, seeded contract-first with `authorWarn` (its seven drilled rows — five children and `watermark`'s two — `planned` with it) because the pull executor is the next stage. **Live since 2026-10-01 (#20919)**: the connector sync executor reads every key. The container's `authorWarn` left with that re-grade (#21127) — kept at first, it made the author-side lint throw, so `os validate` / `os lint` exited 1 on every stack that authored the binding; the caveat it carried (nothing schedules a pull until the `job` stage lands) is on the key's description | | picklist | seeded 2026-09-30 (#19518) with every key `planned`, ahead of its runtime reader by ruling; **live since the runtime layer (#19519)**: the ObjectQL registry merges `picklistExtensions` into the list (additive only, a repeated value refused) and resolves it onto every field that names it, the write door judges against that set, and a field naming an undeclared list fails the boot. `field.picklist` left `authorWarn` with it. Display keys (`label`, `description`, `options.color`) are graded by their served form. | diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json index b023ef9e041..bdf8cca8fbc 100644 --- a/packages/spec/liveness/email_template.json +++ b/packages/spec/liveness/email_template.json @@ -1,85 +1,94 @@ { "type": "email_template", - "_note": "EmailTemplateDefinitionSchema. THE WHOLE SURFACE WENT LIVE with the #4509 materializer bridge — this file previously recorded the webhook (#3461) disconnect shape, all 13 props dead, and it is kept as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING. What closed it: `bootstrapDeclaredEmailTemplates` (packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts) validates each declared item through `EmailTemplateDefinitionSchema.parse()` (in `upsertDeclaredEmailTemplate` — the spec schema finally has a real consumer, defaults and all) and materializes it into the `sys_email_template` ROW that the execution point reads, via `mapTemplateToRow` — the SAME mapping the built-in seeder uses (`EmailServicePlugin.upsertTemplate`), shared deliberately so the two doors cannot drift. The execution end is unchanged in mechanism but NOT in shape: `IEmailService.sendTemplate` resolves (name, locale) with an en-US fallback and honors active/variables/fromOverride/replyTo off the row — except that since #9225 the resolve-and-render half lives in `EmailService.resolveAndRenderTemplate`, shared with the render-only `renderTemplate`, and `sendTemplate` keeps only the envelope reads. THREE breaks had to close, not one: the engine never registered authored `emailTemplates:` into the registry at all (objectql/src/engine.ts metadataArrayKeys — the key was simply missing, so the bridge's source was empty); built-in seeds left `managed_by` at the column default 'admin', which made them masquerade as admin-authored and permanently outrank a declared template (`upsertTemplate` now stamps 'platform'); and only then did the bridge's write land. The ADR-0054 proof pins all three by asserting the AUTHORED wording is what sendTemplate actually renders. Runtime authoring is covered too: email_template is allowRuntimeCreate:true (unlike webhook), so a boot-only sweep would leave a Studio save inert until restart — the plugin also subscribes to `email_template` metadata changes and re-materializes the single changed item (`EmailServicePlugin.wireTemplateMutationProjection`). Seed-not-clobber mirrors sys_webhook (#3489): declared rows seed as `managed_by:'package'` and re-seed every boot, but an admin-authored ('admin') or admin-edited ('customized', stamped by email-template-provenance.ts) row is never overwritten — a reworded transactional mail survives redeploys. Nothing carries an authorWarn any more: authoring is no longer a no-op. `protection`/_lock*/_provenance are framework overlay fields, auto-live. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol; the file-level pointers above were carrying line numbers of their own and are now named by symbol for the same reason.", + "_note": "EmailTemplateDefinitionSchema. THE WHOLE SURFACE WENT LIVE with the #4509 materializer bridge — this file previously recorded the webhook (#3461) disconnect shape, all 13 props dead, and it is kept as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING. What carries it NOW (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the bridge that copied declared items into `sys_email_template` rows retired together with the built-in seed, the boot sweep and the live projector. `createRegistryEmailTemplateLoader` (packages/plugins/plugin-email/src/template-loader.ts) reads `email_template` metadata from the registry by (name, locale) at every send — the protocol's layered execution list, the environment's item over each code package's declaration — and the built-in auth templates are code-declared items of the email plugin's own package (`EmailServicePlugin.init` registers them through its manifest). Each candidate is validated through `EmailTemplateDefinitionSchema` (`safeParse` in the loader — the spec schema keeps a real consumer, defaults and all; a malformed item refuses the send instead of falling through to the built-in) and carried into the render input by `toRenderInput`; `EmailService.resolveAndRenderTemplate` renders it and `sendTemplate` keeps the envelope reads. A Studio save is in the layered list the moment it lands, so it is live on the next send with no subscription and no projection. The ADR-0054 proof (`email-template-materialization`) asserts that an app-authored override of a BUILT-IN auth template is what reaches the transport, that the built-in still serves the locales the app leaves out, and that a Studio save over the authored item sends without a restart. Two keys lost their only consumer with the rows: `category` and `isSystem` were read by nothing but the row projection (a Setup column and the built-in seeder's stand-down), and are `dead` from 2026-10-11 — see their rows. `label` and `description` are read by the Studio metadata list. `protection`/_lock*/_provenance are framework overlay fields, auto-live. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol.", "props": { "name": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`name: tpl.name` — the row projection, and half the (name, locale) upsert key `upsertDeclaredEmailTemplate` matches on); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`loader.load(input.template, wanted)` — the send-end lookup key, and `TEMPLATE_NOT_FOUND: ${input.template}` when it misses)", - "note": "The (name, locale) resolution key at both ends of the bridge. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE (`:78` still names the column), so that half is a pure grammar migration; the send-end leg had rotted. `email-service.ts:411` lands on a docblock line of the `TemplateLoader` interface (\"or `null` when none exists / inactive\"), ~780 lines above the read. ALL NINE of this file's `email-service.ts` citations rotted together and by the same cause: #9225 split the resolver out of `sendTemplate` into `resolveAndRenderTemplate`, and the reads moved with it from the 400s to the 1170-1310 band. That is the drift no existing check can see — the file exists, every cited line is still inside it, and the file still names each key, so the existence check, the line bound and the key-mention check all pass on a pointer that is ~750 lines wrong. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#createRegistryEmailTemplateLoader (`(item as Record).name === name` — the bundle filter, half of the (name, locale) key); packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`name: tpl.name`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`loader.load(input.template, wanted)` — the send-end lookup key, and `TEMPLATE_NOT_FOUND: ${input.template}` when it misses)", + "note": "The (name, locale) resolution key, read by the loader on the registry list and by the service at the send. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "label": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`label: tpl.label`)", - "note": "Materialized into the `label` column — the Studio row title (sys_email_template nameField). 2026-08-28: RE-ANCHORED (commit 8cb96ec41) — `:79` was ACCURATE, so this is a grammar migration only. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "objectui @0df67f23: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (the Studio metadata list's default `label` column — `email_template` registers no `listColumns` of its own, so every row of its list renders the authored label)", + "note": "Docs-shaped: shown to a human in the Studio metadata list, never by the send path. Until 2026-10-11 it was materialized into the `sys_email_template.label` column (the Setup record title); that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 0df67f23 — is the consumer now, under the designer-previews-count-as-consumers ruling (#7131).", + "evidenceScope": "cross-repo" }, "category": { - "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`category: tpl.category`)", - "note": "Materialized into `category`; a Studio filter facet on the row, never send behavior. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) — `:80` was ACCURATE, so this is a grammar migration only. Re-closed by hand against 93ea19bca." + "status": "dead", + "verifiedAt": "2026-10-11", + "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): the key's only consumer was the row projection into `sys_email_template.category` (a Setup column and index), which retired with the rows. Measured: no reader in this repository (plugin-email, service-messaging, plugin-auth), and at the `.objectui-sha` pin 0df67f23 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts`), never as the Studio list facet the schema's own doc promises (`ResourceListPage.tsx#defaultColumns` renders name/label/description). Tracked for enforce-or-remove (ADR-0049). ⚠️ The schema DEFAULTS it (`'custom'`), so presence is not authorship — the mapping precedent (`errorPolicy`/`batchSize`) is why removal, not a warning, is the channel that reaches an author." }, "locale": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`locale: tpl.locale` — the row projection, and the other half of the upsert key); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (the (name, locale) ladder: the caller's locale, then the documented default, then the loader's own no-locale answer); packages/plugins/plugin-email/src/email-service.ts#DEFAULT_TEMPLATE_LOCALE (the `en-US` the ladder's second rung asks for BY NAME — #7731 is what happened while that rung passed `undefined`)", - "note": "Load-bearing in both directions: the same template in two locales materializes as two rows, and the recipient's locale picks between them. The resolved row's locale is also the render locale for ADR-0053 format filters. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:411-416` had rotted onto the `TemplateLoader` docblock, the same #9225 resolver split that moved every `email-service.ts` citation in this file. The ladder is now anchored to the function that runs it plus the constant it names, so a rung deleted or the default renamed goes red instead of drifting. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#createRegistryEmailTemplateLoader (the per-locale pick — an exact (name, locale) slot, then the bundle's lowest locale tag for a call that named none); packages/plugins/plugin-email/src/template-loader.ts#itemLocale (an item that declares no locale is the schema default's member); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (the (name, locale) ladder: the caller's locale, then the documented default, then the loader's own no-locale answer); packages/plugins/plugin-email/src/email-service.ts#DEFAULT_TEMPLATE_LOCALE (the `en-US` the ladder's second rung asks for BY NAME — #7731 is what happened while that rung passed `undefined`)", + "note": "Load-bearing in both directions: the same template in two locales is two members of one bundle, and the recipient's locale picks between them. The resolved member's locale is also the render locale for ADR-0053 format filters. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "subject": { "status": "live", - "verifiedAt": "2026-08-28", + "verifiedAt": "2026-10-11", "proof": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts#email-template-materialization", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`subject: tpl.subject`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`renderTemplate(row.subject, data, renderOpts)` — the {{path}} holes filled with the resolved row's locale)", - "note": "ADR-0054 high-risk class (email-template-materialization): the authored subject is the representative check for the whole authoring→send pipeline, which crosses manifest-decomposition → the ObjectQL registry → the materializer → the sys_email_template row → the TemplateLoader → sendTemplate. Exactly the multi-layer seam that was broken here, and in three places at once. The proof authors a stack `emailTemplates:` entry overriding a BUILT-IN auth template and asserts the authored wording is what reaches the transport — the ADR-0078 false-compliance case #4509 named (an admin 'fixes' the password-reset mail and users keep receiving the built-in copy). 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:444` had rotted onto an `EmailServiceOptions` docblock about sys_email PERSISTENCE, which is the most misleading rot in this file because it still reads as email-service configuration. The render moved with the #9225 resolver split. Re-closed by hand against 93ea19bca." + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`subject: tpl.subject`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`renderPlainTextTemplate(row.subject, data, renderOpts)` — the {{path}} holes filled with the resolved member's locale)", + "note": "ADR-0054 high-risk class (email-template-materialization): the authored subject is the representative check for the whole authoring→send pipeline, which now crosses manifest-decomposition → the ObjectQL registry → the protocol's layered list → the registry loader → sendTemplate. The proof authors a stack `emailTemplates:` entry overriding a BUILT-IN auth template and asserts the authored wording is what reaches the transport — the ADR-0078 false-compliance case #4509 named (an admin 'fixes' the password-reset mail and users keep receiving the built-in copy). 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "bodyHtml": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`body_html: tpl.bodyHtml` — the camelCase→snake_case remap); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`renderTemplate(row.body_html, data, renderOpts)`)", - "note": "The camelCase→snake_case remap the proof asserts alongside the subject. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:445` had rotted onto `persistence?: EmailPersistence;`, an options field, ~800 lines above the render. Same #9225 resolver split. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`body_html: tpl.bodyHtml` — the camelCase→snake_case remap into the render input); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`renderTemplate(row.body_html, data, renderOpts)`)", + "note": "The camelCase→snake_case remap, now into the render input rather than a row column. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "bodyText": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`...(tpl.bodyText ? { body_text: tpl.bodyText } : {})` — omitted, never nulled); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`row.body_text ? renderTemplate(row.body_text, …) : htmlToText(html)` — the auto-derive fallback)", - "note": "Omitted from the row rather than nulled when unset, so a re-seed never blanks it; the send path then auto-derives text from HTML (htmlToText). 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:446-448` had rotted onto the `templateLoader` / `retries` option docblocks. Same #9225 resolver split. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`...(tpl.bodyText ? { body_text: tpl.bodyText } : {})` — omitted, never nulled); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`row.body_text ? renderPlainTextTemplate(row.body_text, …) : htmlToText(html)` — the auto-derive fallback)", + "note": "Omitted from the render input rather than nulled when unset, so the send path auto-derives text from HTML (htmlToText) exactly when no bodyText was authored. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "variables": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {})`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (parses `row.variables_json`, filters to the `required` ones and calls `requireVars` — a missing one throws MISSING_VARIABLES and the send never happens)", - "note": "All four child keys share this verdict — not drilled. `required` is a real runtime gate, not a hint. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — BOTH legs were wrong, and the bootstrap one is the sharpest rot in this file: `bootstrap-declared-email-templates.ts:91` lands on `is_system: tpl.isSystem`, i.e. on the line `isSystem` cites for ITSELF. Two entries citing one line for two different keys is a state the ledger cannot detect from the inside — the file names both keys, so key-mention anchors both, and the row projection really is 15 lines of near-identical shape. The real mapping is the last line of the same object literal. `email-service.ts:427-431` had rotted onto the `EmailTemplateRow` interface — the row SHAPE rather than the gate that reads it, which is the plausible-sibling failure key-mention was built for and cannot catch inside one file. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {})`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (parses `row.variables_json`, filters to the `required` ones and calls `requireVars` — a missing one throws MISSING_VARIABLES and the send never happens)", + "note": "All four child keys share this verdict — not drilled. `required` is a real runtime gate, not a hint. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "fromOverride": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (fromOverride.address/.name → from_address/from_name, address-gated); packages/plugins/plugin-email/src/email-service.ts#sendTemplate (`input.from ?? (row.from_address ? { address: row.from_address, … } : undefined)` — the caller still wins); packages/plugins/plugin-email/src/email-service.ts#rowEnvelope (the generic row→message path, which REFUSES a row with no from_address)", - "note": "Both child keys share this verdict. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:450-453` had rotted onto the `logger` / `defaultTemplateContext` option fields. The send-end read stayed in `sendTemplate` across the #9225 split (it is an envelope read, not a render read), which is exactly why anchoring is worth doing here: the two halves of this entry moved apart and only the symbol names say which is which. `rowEnvelope` is added as the second local consumer — it was never cited, and it is the one that turns a missing from_address into a refusal rather than a silent send. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (fromOverride.address/.name → from_address/from_name, address-gated); packages/plugins/plugin-email/src/email-service.ts#sendTemplate (`input.from ?? (row.from_address ? { address: row.from_address, … } : undefined)` — the caller still wins); packages/plugins/plugin-email/src/email-service.ts#rowEnvelope (the persisted sys_email row's projection onto the outbound message, which REFUSES a row with no from_address)", + "note": "Both child keys share this verdict. The send-end read is an envelope read in `sendTemplate`, not a render read. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "replyTo": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`...(tpl.replyTo ? { reply_to: tpl.replyTo } : {})`); packages/plugins/plugin-email/src/email-service.ts#sendTemplate (`input.replyTo ?? row.reply_to` — the row's value used only when the caller passes none); packages/plugins/plugin-email/src/email-service.ts#rowEnvelope (`if (row.reply_to) msg.replyTo = String(row.reply_to)` — the persisted row's own projection onto the outbound message)", - "note": "Honored on the outbound message when the caller passes no explicit replyTo. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:139` had rotted onto a docblock about the sys_email outbox id contract, ~1150 lines above the read and about a completely different mechanism. That is the largest single drift measured in this batch, and it passed every check for the usual reason. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`...(tpl.replyTo ? { reply_to: tpl.replyTo } : {})`); packages/plugins/plugin-email/src/email-service.ts#sendTemplate (`input.replyTo ?? row.reply_to` — the template's value used only when the caller passes none); packages/plugins/plugin-email/src/email-service.ts#rowEnvelope (`if (row.reply_to) msg.replyTo = String(row.reply_to)` — the persisted sys_email row's own projection onto the outbound message)", + "note": "Honored on the outbound message when the caller passes no explicit replyTo. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "active": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`active: tpl.active`); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`if (row.active === false) throw new Error('TEMPLATE_INACTIVE: …')` — the send refuses before rendering); packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#deactivateDeclaredEmailTemplate (the withdrawal path: a deleted declared template flips `active: false` on its package-managed rows rather than destroying them)", - "note": "Also the withdrawal mechanism: deleting a declared template deactivates its rows rather than destroying them. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-service.ts:418-419` had rotted onto the closing lines of the `TemplateLoader` docblock (`:419` is the bare `*/`), and the note's `bootstrap-declared-email-templates.ts:201` pointer — never in `evidence`, so nothing ever bounded it — lands on `if (row?.id) {` inside the seed-not-clobber branch, a different mechanism in the same file. The withdrawal path is now cited in `evidence` under its own symbol, where the checks can reach it. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "packages/plugins/plugin-email/src/template-loader.ts#toRenderInput (`active: tpl.active` — the schema default `true` applied by the loader's parse); packages/plugins/plugin-email/src/email-service.ts#resolveAndRenderTemplate (`if (row.active === false) throw new Error(`TEMPLATE_INACTIVE: …`)` — the send refuses before rendering)", + "note": "The withdrawal path is no longer this key: a template withdrawn from the registry (a deleted declaration, a discarded environment item) leaves the layered list and resolves to nothing (TEMPLATE_NOT_FOUND), where the retired bridge used to deactivate its rows. `active: false` remains the authored way to stop a template sending while keeping it. 2026-10-11: RE-ANCHORED by hand to the registry loader (ADR-0131 D6/D10, ruling C on §6 Q1). The row path this entry cited — the boot seed, the declared-template sweep, `mapTemplateToRow`, the live projector — retired; templates resolve from the registry by (name, locale), so each key is now cited where the loader carries it into the render (`toRenderInput`) and where the service reads it.", + "evidenceScope": "in-repo" }, "isSystem": { - "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`is_system: tpl.isSystem`); packages/plugins/plugin-email/src/email-plugin.ts#upsertTemplate (`if (existingRow.is_system === false) return;` — the built-in seeder stands down rather than reclaiming the row)", - "note": "The BUILT-IN seeder's provenance axis, distinct from the declared bridge's managed_by/customized pair — a declared template lands is_system:false, which is what stops the built-in auth seed reclaiming the row on the next boot. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — the bootstrap leg was ACCURATE; `email-plugin.ts:544` had rotted onto `const result = await target.sendInline({` inside the operator's TEST-MAIL button, ~980 lines from the gate and about sending rather than seeding. `email-plugin.ts` is 1550 lines and names `is_system` on exactly four of them, three of which are prose — a file that large is where a line citation is worth least and an anchor most. Re-closed by hand against 93ea19bca." + "status": "dead", + "verifiedAt": "2026-10-11", + "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): its only consumers were the built-in seeder's stand-down (`EmailServicePlugin.upsertTemplate`: `if (existingRow.is_system === false) return;`) and the `sys_email_template.is_system` column, both retired with the rows. Measured: no reader in this repository, and at the `.objectui-sha` pin 0df67f23 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts`). The schema's own claim — a system template \"SHOULD NOT be deleted by tenants\" — is enforced by nothing: the built-ins are code-declared items, so the metadata door can only discard an environment item over them, whatever this key says. Tracked for enforce-or-remove (ADR-0049)." }, "description": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts#mapTemplateToRow (`...(tpl.description ? { description: tpl.description } : {})`)", - "note": "Materialized into `description`; docs-shaped — read by humans in Studio, never by the send path. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) — `:92` was ACCURATE, so this is a grammar migration only. Re-closed by hand against 93ea19bca." + "verifiedAt": "2026-10-11", + "evidence": "objectui @0df67f23: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (the Studio metadata list's default `description` column — `email_template` registers no `listColumns` of its own)", + "note": "Docs-shaped: read by humans in Studio, never by the send path. Until 2026-10-11 it was materialized into `sys_email_template.description`; that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 0df67f23 — is the consumer now (#7131).", + "evidenceScope": "cross-repo" } } } diff --git a/packages/spec/liveness/state-counts/email_template.md b/packages/spec/liveness/state-counts/email_template.md index 49daed28b3e..df1fc9eb239 100644 --- a/packages/spec/liveness/state-counts/email_template.md +++ b/packages/spec/liveness/state-counts/email_template.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | +| `email_template` | 19 | 0 | 0 | 2 | 0 | 21 | diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 93f9cf7da49..92c519f3e43 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -198,7 +198,7 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * `configSchema` literal — so requiredness lives here and in the * execute-time guard, not in the form. * - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):** - * `template` names a `sys_email_template` bundle + * `template` names an `email_template` bundle * (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the * delivery path resolves `(name, locale)` at delivery time via * `IEmailService.sendTemplate({ template, locale })`. @@ -284,10 +284,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ message: templateExpressionInput(ExpressionSchema, notifyTemplateRefusals('message')).optional() .describe('Notification body — the same template input as `title` (a bare string or a `{ dialect: \'template\', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`.'), /** - * The localizable content path (#9205): name of a `sys_email_template` + * The localizable content path (#9205): name of an `email_template` * bundle. Resolved by `(name, locale)` AT DELIVERY TIME — - * `IEmailService.sendTemplate({ template, locale })` picks that locale's row - * with the documented en-US fallback ladder. + * `IEmailService.sendTemplate({ template, locale })` picks that locale's + * member with the documented en-US fallback ladder. * * The `locale` is resolved PER RECIPIENT, after fan-out (maintainer ruling * 2026-09-01, #13881): the recipient's own `sys_user.locale` when set, else @@ -305,7 +305,7 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * in this comment only (#22093). */ template: z.string().optional() - .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node\'s `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), + .describe('Email template name (an `email_template` item\'s `name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` among the email templates — built-in, declared by an app, or authored in Studio — at delivery time and renders subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient\'s own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node\'s `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), /** * Render context for the referenced template's `{{var}}` holes. Values are * interpolated per run (`{record.x}` resolves), so flow state can feed the @@ -368,10 +368,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ path: ['template'], message: '`template` cannot be combined with inline `title`/`message` — pick ONE content path: ' - + '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being ' + + '`template` (localizable: resolves `(name, locale)` among the email templates at delivery, the locale being ' + 'each recipient\'s own `sys_user.locale` or the deployment default — resolved per recipient, after fan-out) ' + 'or inline `title` + `message` (one text for every recipient, not localizable). To localize, keep `template`, move ' - + 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would ' + + 'the text into the template bundle\'s locale members, and delete `title`/`message`; runtime precedence would ' + 'silently ignore one of them.', }); } @@ -390,7 +390,7 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ path: ['title'], message: 'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` ' - + 'reference resolving a sys_email_template bundle at delivery in each recipient\'s locale ' + + 'reference resolving an email template bundle at delivery in each recipient\'s locale ' + '(the recipient\'s own `sys_user.locale` or the deployment default — resolved per recipient, after fan-out). ' + 'Neither was given, so there is nothing to deliver.', }); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 87df910b319..a1d44ed5040 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -143,15 +143,17 @@ export interface SendEmailResult { } /** - * Input for IEmailService.sendTemplate(). Resolves a named template - * row from `sys_email_template`, renders it against `data`, and - * forwards through the same transport pipeline as `send()`. + * Input for IEmailService.sendTemplate(). Resolves a named `email_template` + * from the metadata registry — the environment's item over each code + * package's declaration, the built-in auth templates included — renders it + * against `data`, and forwards through the same transport pipeline as + * `send()`. */ export interface SendTemplateInput { /** - * Template identifier (matches `sys_email_template.name`), e.g. + * Template identifier (the `email_template` item's `name`), e.g. * `'auth.password_reset'`. There is no "best match" and no language-subtag - * folding: the locale row is resolved by the exact ladder documented on + * folding: the locale member is resolved by the exact ladder documented on * `locale` below. */ template: string; @@ -168,10 +170,10 @@ export interface SendTemplateInput { * 1. this locale, matched exactly (no language-only prefix matching: `zh` * does not resolve `zh-CN`); * 2. `'en-US'` — which is also where a call that omits `locale` STARTS, so - * "no locale" means the default rather than an arbitrary row; + * "no locale" means the default rather than an arbitrary member; * 3. only for a call that named no locale, and only when the bundle has no - * `en-US` row at all: its lowest locale tag, so a single-locale tenant - * keeps rendering and does so identically on every boot. + * `en-US` member at all: its lowest locale tag, so a single-locale + * bundle keeps rendering and does so identically on every boot. */ locale?: string; /** @@ -228,7 +230,7 @@ export interface SendTemplateInput { */ export interface RenderTemplateInput { /** - * Template identifier (matches `sys_email_template.name`), e.g. + * Template identifier (the `email_template` item's `name`), e.g. * `'auth.password_reset'`. */ template: string; @@ -250,8 +252,8 @@ export interface RenderTemplateInput { /** * Outcome of IEmailService.renderTemplate() — the rendered content of the - * resolved `sys_email_template` row, mirroring what the row itself carries: - * a subject, an HTML body, and a plain-text body (the row's `body_text` + * resolved `email_template`, mirroring what the template itself carries: + * a subject, an HTML body, and a plain-text body (the template's `bodyText` * when present, otherwise derived from the rendered HTML). Consumers pick * the face they need — an email composer uses `html`/`text`, an in-app * inbox row uses `subject`/`text`. @@ -259,10 +261,10 @@ export interface RenderTemplateInput { export interface RenderTemplateResult { /** Rendered subject line. */ subject: string; - /** Rendered HTML body (`sys_email_template.body_html`). */ + /** Rendered HTML body (the template's `bodyHtml`). */ html: string; /** - * Rendered plain-text body — the row's `body_text` when declared, + * Rendered plain-text body — the template's `bodyText` when declared, * otherwise derived from the rendered HTML. */ text: string; @@ -281,21 +283,21 @@ export interface IEmailService { send(input: SendEmailInput): Promise; /** - * Resolve a named template from `sys_email_template`, render its + * Resolve a named `email_template` from the metadata registry, render its * subject/body against `input.data`, then deliver via `send()`. * * Locale resolution is the ladder on {@link SendTemplateInput.locale}. * * Errors: - * - `TEMPLATE_NOT_FOUND` — no row matches `(name, locale|en-US)`, and (for a - * call that named no locale) the name carries no rows at all. - * - `TEMPLATE_INACTIVE` — row exists but `active=false`. + * - `TEMPLATE_NOT_FOUND` — no template matches `(name, locale|en-US)`, and + * (for a call that named no locale) the name carries no members at all. + * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. */ sendTemplate(input: SendTemplateInput): Promise; /** - * Resolve a named template from `sys_email_template` and render its + * Resolve a named `email_template` from the metadata registry and render its * subject/body against `input.data` — WITHOUT sending anything. Strictly * render-only: no delivery, no queueing, no `sys_email` row. One resolver, * many channels (#9225): the same locale ladder + `{{var}}` renderer @@ -306,9 +308,9 @@ export interface IEmailService { * Locale resolution is the ladder on {@link SendTemplateInput.locale}. * * Errors (same vocabulary as `sendTemplate`, since resolution is shared): - * - `TEMPLATE_NOT_FOUND` — no row matches `(name, locale|en-US)`, and (for a - * call that named no locale) the name carries no rows at all. - * - `TEMPLATE_INACTIVE` — row exists but `active=false`. + * - `TEMPLATE_NOT_FOUND` — no template matches `(name, locale|en-US)`, and + * (for a call that named no locale) the name carries no members at all. + * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. */ renderTemplate(input: RenderTemplateInput): Promise; diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 915c2e31267..b02d8068be8 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -10,10 +10,12 @@ import { strictObject } from '../shared/strict-object'; * Email Template Metadata Protocol * * Declarative template definition consumed by `IEmailService.sendTemplate()` - * to render outbound mail. Persisted as rows of `sys_email_template` so - * administrators can author/edit/translate templates in Studio without - * shipping code, and tenants can override the built-in defaults - * (`allowOrgOverride: true` in the metadata registry). + * to render outbound mail. Read from the metadata registry by + * `(name, locale)` — the environment's item over each code package's + * declaration, the built-in auth templates included — so administrators can + * author/edit/translate templates in Studio without shipping code, and a + * Studio save overrides a built-in default at environment level. No + * `sys_email_template` row is read or written on the way. * * Aligned with Salesforce `EmailTemplate` and ServiceNow * `sysevent_email_action` conventions: a single named template is diff --git a/packages/spec/src/system/notification.zod.ts b/packages/spec/src/system/notification.zod.ts index aeb74fe9372..89ee2c30f46 100644 --- a/packages/spec/src/system/notification.zod.ts +++ b/packages/spec/src/system/notification.zod.ts @@ -49,8 +49,8 @@ export const NotificationChannelSchema = lazySchema(() => z.enum([ // it (this vocabulary is where the confusion kept starting): // - email — the `email_template` metadata kind, whose ONE canonical schema // is `EmailTemplateDefinitionSchema` (`system/email-template.zod.ts`, -// registered in `BUILTIN_METADATA_TYPE_SCHEMAS`), materialized into -// `sys_email_template` rows by `plugin-email`. The `EmailTemplateSchema` +// registered in `BUILTIN_METADATA_TYPE_SCHEMAS`), read from the registry +// by `plugin-email` at every send (no row). The `EmailTemplateSchema` // removed here was the *legacy* shape spec 7.1.0 explicitly demoted when // it resolved that Prime Directive #8 double-declaration, keeping it // "only as an inline sub-shape inside `Notification`" — the holder #4610 From 94ab10722bcffc96bd5d3d1661f6a433ec3f3f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:45:48 +0000 Subject: [PATCH 07/18] docs(email-templates, system-context): say where a template is read now, not the retired rows Cross-lane, kept in its own commit: both pages state the row path this PR retires, and the role file makes text this change falsifies a must-fix. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/automation/email-templates.mdx | 44 +++++++++++---------- content/docs/permissions/system-context.mdx | 2 +- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index d36eefb9121..fb9c4c779a4 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -168,11 +168,11 @@ the localizable path: ``` - `template` names the bundle. The delivery path resolves `(name, locale)` - against `sys_email_template` at delivery time and renders subject/body from - that row. **The locale is resolved per recipient, after fan-out**: the + among the email templates at delivery time and renders subject/body from + that member. **The locale is resolved per recipient, after fan-out**: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so two recipients of one notification - whose `locale` columns differ receive different rows of the same bundle + whose `locale` columns differ receive different members of the same bundle (maintainer ruling, 2026-09-01). A recipient whose column is empty or unset always falls back to the deployment default; no value ever dead-letters a delivery. A `payload.locale` set by the producer — the pre-ruling single @@ -212,24 +212,26 @@ instead of duplicating it. Worth knowing, because it explains what an administrator can and cannot change. -`sendTemplate` resolves rows of the `sys_email_template` platform object, not -your source files. At boot the email plugin **materializes** every declared -`email_template` into that object — validating each one through the canonical -schema first, so a malformed template is a warning rather than a broken boot. -Runtime saves are materialized on the same seam, so a Studio edit takes effect -without a restart. - -Change a template's wording in **Studio** — the `email_template` metadata. The -`sys_email_template` rows themselves are closed to organization writes: a create -or an edit through the data API is refused `403 PERMISSION_DENIED`, and only the -platform writes them, from the built-in templates and the `email_template` -metadata. - -Materialization is **seed-not-clobber**. Declared templates carry package -provenance and are re-seeded on every boot, but a row an administrator created -or edited before that door closed is never overwritten. That is also the reason -a source change that "does not take effect" is usually such a customized row -winning, not a failed seed. +`sendTemplate` resolves the `email_template` **metadata** itself, by +`(name, locale)`, at every send — the same item `GET /api/v1/meta/email_template` +serves. Three populations live there: the built-in auth templates the email +plugin declares, the templates your app declares, and the ones saved in Studio, +which land at environment level and override a declaration of the same +`(name, locale)`. A template your app declares overrides the built-in copy of +its `(name, locale)`; the built-in still serves the locales your app leaves out. +Each template is validated through the canonical schema when it is resolved, so +a malformed one fails its send loudly instead of falling through to the +built-in copy. + +Change a template's wording in **Studio** — the `email_template` metadata. A +save is live on the very next send, with no restart. + +The `sys_email_template` platform object is no longer read or written by the +send path. It keeps the rows earlier releases wrote, and it is closed to +organization writes: a create or an edit through the data API is refused +`403 PERMISSION_DENIED`. A row an administrator customized before that door +closed is not what sends; the v18 upgrade promotes such rows to +environment-level templates. `bodyText` is optional: when you omit it the service derives a plain-text alternative by stripping tags from the rendered HTML. Authoring one explicitly diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ed8898198c7..9241628ae45 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -183,7 +183,7 @@ The largest single consumer — **17 of the 122 sites**. | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` | -| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | +| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | | 59 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | | 60 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | | 61 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` | From 218e5ce92ec92b3d5fa488eaccb434f0883b6b3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:53:36 +0000 Subject: [PATCH 08/18] chore(spec): regenerate the reference docs for the email-template describe text (gen:docs) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/references/automation/io-node-config.mdx | 2 +- content/docs/references/system/email-template.mdx | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index f7eaee13fe4..5d4b33f52a5 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data); | **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | | **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is rendered per run with `{{ }}` holes over the flow's variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount \| currency }}`); a single-brace `{token}` is refused. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`. | -| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | +| **template** | `string` | optional | Email template name (an `email_template` item's `name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` among the email templates — built-in, declared by an app, or authored in Studio — at delivery time and renders subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | | **templateData** | `Record` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. | | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | | **topic** | `string` | optional | Event topic (default: "notify") | diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 2441cd0907e..5c37dfba0ac 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -9,10 +9,12 @@ description: "Declarative template definition consumed by IEmailService.sendTemp Email Template Metadata Protocol Declarative template definition consumed by `IEmailService.sendTemplate()` -to render outbound mail. Persisted as rows of `sys_email_template` so -administrators can author/edit/translate templates in Studio without -shipping code, and tenants can override the built-in defaults -(`allowOrgOverride: true` in the metadata registry). +to render outbound mail. Read from the metadata registry by +`(name, locale)` — the environment's item over each code package's +declaration, the built-in auth templates included — so administrators can +author/edit/translate templates in Studio without shipping code, and a +Studio save overrides a built-in default at environment level. No +`sys_email_template` row is read or written on the way. Aligned with Salesforce `EmailTemplate` and ServiceNow `sysevent_email_action` conventions: a single named template is From fcd3b610aa58dd58881c5127eabc3d40b09c994d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:55:48 +0000 Subject: [PATCH 09/18] chore(changeset): email templates resolve from the registry (plugin-email minor, BREAKING; spec, service-automation patch) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../15205-email-templates-resolve-registry.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/15205-email-templates-resolve-registry.md diff --git a/.changeset/15205-email-templates-resolve-registry.md b/.changeset/15205-email-templates-resolve-registry.md new file mode 100644 index 00000000000..5d3d5e9c121 --- /dev/null +++ b/.changeset/15205-email-templates-resolve-registry.md @@ -0,0 +1,36 @@ +--- +'@objectstack/plugin-email': minor +'@objectstack/spec': patch +'@objectstack/service-automation': patch +--- + +feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + +Clause-②: no + + + +**BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + +**What changes.** + +- **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. +- **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). +- **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. +- **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. +- **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + +**What moves for consumers.** + +- FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. +- FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. +- FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. +- FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. +- FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. +- FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + +**What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + +**`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + +**`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. From 7e9137b0a004b457d346e2685e931ba4ca274d72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:11:49 +0000 Subject: [PATCH 10/18] test(spec): the notify template describe pin asserts the email templates, not the retired rows Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- packages/spec/src/automation/io-node-config.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 22069c59a06..8e4e4866bd6 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -336,7 +336,13 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => // The contract: resolves by (name, locale) at delivery time… expect(templateDoc).toMatch(/\(name, locale\)/); expect(templateDoc).toMatch(/delivery time/); - expect(templateDoc).toContain('sys_email_template'); + // …among the email templates themselves (ADR-0131 D6/D10): the + // `email_template` items the registry serves — built-in, declared by an + // app, or authored in Studio — and never the retired `sys_email_template` + // rows, which no send reads any more. + expect(templateDoc).toContain('`email_template`'); + expect(templateDoc).toMatch(/built-in, declared by an app, or authored in Studio/); + expect(templateDoc).not.toContain('sys_email_template'); // …with the locale named as what it IS since #13881 — resolved per // recipient, after fan-out, from the recipient's own `sys_user.locale` // with the deployment default underneath. Both rungs are pinned by From 8b28ea154ec45f1f1823d619183cd16e945bd37d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:10:47 +0000 Subject: [PATCH 11/18] chore(gates): ratchet the ledgers that named the retired template path; regenerate the two censuses - engine-double-contract: both rewritten plugin-email doubles keep their pinned update() and route it through assertEngineUpdateDispatch; the baseline row for the deleted bootstrap test is removed (shrink). - objectql-double-limit baseline: three rows for files now clean or gone removed (shrink). - isystem-census: the NON_ELEVATION_READS row for the deleted bootstrap-declared-email-templates.ts removed; gen:system-context-census rewrote the declared counts. - measure-durability-swallow-family: the departure-ONE resolution control that pinned EmailServicePlugin.upsertTemplate is re-pointed, by measurement, to a site that still exists. - tenant-audit-census --write, plus the page's hand-written figures (233 -> 228, 158 -> 156, 75 -> 72, 126 -> 121). Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 4 +- .../docs/permissions/tenant-audit-census.mdx | 38 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 22 +++++------ ...mail-plugin.template-runtime-write.test.ts | 12 ++++-- ...-shutdown-detaches-template-bridge.test.ts | 11 +++++- scripts/engine-double-contract.baseline.json | 8 ---- scripts/isystem-census.mjs | 6 --- scripts/measure-durability-swallow-family.mjs | 28 +++++++------- scripts/objectql-double-limit.baseline.json | 9 ----- 9 files changed, 63 insertions(+), 75 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 9241628ae45..149c615385e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -359,10 +359,10 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 128 | ✅ | +| — parsed as a property **read** | 127 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | -| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | +| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | | Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ | | — behaviour-bearing (rows 1–61 above) | 119 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index c43f2455c27..1b5837b389a 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 233 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 228 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **233** | +| 175 write call sites | quoted in the merged changeset | **228** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 233** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 99 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 228** decidable, **72** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 121 decidably elevated, 0 decidably not, 99 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -126 of 233 (54%) as decidably elevated, with 99 more whose elevation is a +121 of 228 (53%) as decidably elevated, with 99 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 228`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **233** | -| …whose object name is statically decidable | 158 | -| …whose object name is chosen at run time | 75 | -| …against an object with tenancy ENABLED | 157 | +| write call sites on the application surface | **228** | +| …whose object name is statically decidable | 156 | +| …whose object name is chosen at run time | 72 | +| …against an object with tenancy ENABLED | 155 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 172 | +| threading a tenant context | 167 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 126 | +| threading a decidably ELEVATED (`isSystem`) context | 121 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 99 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 187 | +| receiver carried a readable engine type | 182 | | receiver erased, placed by the object NAME | 32 | | receiver erased, placed by an `object: string` PARAMETER | 9 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 105 | +| object name spelled inline | 103 | | object name spelled through a `const` | 53 | -| object name is an `object: string` parameter | 13 | -| object name is some other run-time expression | 62 | +| object name is an `object: string` parameter | 11 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `64e53f1d0`. +Measured on 2026-10-11 at `7e9137b0a`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index c89a1c122c3..a5bee7bbf0c 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 233 | -| Object name statically decidable | 158 | -| Object name chosen at run time | 75 | -| Against a tenancy-enabled object | 157 | +| Write call sites | 228 | +| Object name statically decidable | 156 | +| Object name chosen at run time | 72 | +| Against a tenancy-enabled object | 155 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 172 | +| Threading a tenant context | 167 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 126 | +| Threading a decidably elevated context | 121 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 99 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `64e53f1d0`. +Measured on 2026-10-11 at `7e9137b0a`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | ## Every site @@ -150,12 +150,8 @@ Measured on 2026-10-11 at `64e53f1d0`. | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | | `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | elevated | 2 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | elevated | 1 | | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index f01b7a490d0..1b9e14cf757 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -17,6 +17,7 @@ // `sys_email_template` row is read or written. import { describe, it, expect, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; import { EmailServicePlugin } from './email-plugin.js'; import { BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID } from './template-loader.js'; @@ -32,7 +33,8 @@ type AnyRecord = Record; /** * The slice of ObjectQL the plugin touches, recording every call by object, * so "no `sys_email_template` read or write" is an assertion on calls rather - * than on an empty table. + * than on an empty table. `update` routes through `assertEngineUpdateDispatch` + * so this double cannot accept a dispatch shape the real engine would refuse. */ function fakeEngine(seed: AnyRecord[] = []) { const rows: AnyRecord[] = seed.map((r) => ({ ...r })); @@ -42,10 +44,12 @@ function fakeEngine(seed: AnyRecord[] = []) { calls, registry: { listItems: (_type: string): AnyRecord[] => [] }, async find(object: string) { calls.push({ verb: 'find', object }); return []; }, - async findOne(object: string) { calls.push({ verb: 'findOne', object }); return null; }, async insert(object: string, row: AnyRecord) { calls.push({ verb: 'insert', object }); return { id: row.id }; }, - async update(object: string) { calls.push({ verb: 'update', object }); return { affected: 0 }; }, - async delete(object: string) { calls.push({ verb: 'delete', object }); return { affected: 0 }; }, + async update(object: string, data: AnyRecord, options?: AnyRecord) { + assertEngineUpdateDispatch(data, options); + calls.push({ verb: 'update', object }); + return { affected: 0 }; + }, registerHook() { /* organization door */ }, unregisterHooksByPackage() { return 0; }, }; diff --git a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts index 19bf67a39f0..fc6984424c1 100644 --- a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts +++ b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts @@ -29,6 +29,7 @@ */ import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { EmailServicePlugin } from './email-plugin.js'; @@ -37,13 +38,21 @@ import { EMAIL_TEMPLATE_DOOR_PACKAGE } from './email-template-door.js'; type AnyRecord = Record; type Hook = { event: string; handler: (ctx: AnyRecord) => unknown; packageId?: string }; -/** The slice of ObjectQL the organization door binds to: its hook table. */ +/** + * The slice of ObjectQL the organization door binds to: its hook table, plus + * an `update` that routes through `assertEngineUpdateDispatch` so this double + * cannot accept a dispatch shape the real engine would refuse. + */ function fakeEngine() { const hooks: Hook[] = []; return { hooks, registry: { listItems: () => [] }, async find() { return []; }, + async update(_object: string, data: AnyRecord, options?: AnyRecord) { + assertEngineUpdateDispatch(data, options); + return { affected: 0 }; + }, registerHook(event: string, handler: Hook['handler'], options?: AnyRecord) { hooks.push({ event, handler, packageId: options?.packageId }); }, diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index bdf92642bfb..9f0e7a85b99 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -328,14 +328,6 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 53. The package already depends on @objectstack/objectql (`devDependencies`), so this is a one-line pin whenever a batch takes it — deferred here because #5480's slice is the producer-side predicate plus the gate that reads it, and flipping ~100 unmeasured suites red belongs in the per-package batches that follow, exactly as #5629 did for delete. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "open the fake's update with assertEngineUpdateDispatch(data, options) and run the package's suite" }, - { - "file": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 32. The package already depends on @objectstack/objectql (`devDependencies`), so this is a one-line pin whenever a batch takes it — deferred here because #5480's slice is the producer-side predicate plus the gate that reads it, and flipping ~100 unmeasured suites red belongs in the per-package batches that follow, exactly as #5629 did for delete. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "open the fake's update with assertEngineUpdateDispatch(data, options) and run the package's suite" - }, { "file": "packages/plugins/plugin-email/src/email-plugin.attachment-storage.test.ts", "verb": "update", diff --git a/scripts/isystem-census.mjs b/scripts/isystem-census.mjs index 39e241fa015..a05cbc88c66 100644 --- a/scripts/isystem-census.mjs +++ b/scripts/isystem-census.mjs @@ -107,12 +107,6 @@ export const NON_ELEVATION_READS = [ field: 'Object.isSystem', why: 'same object-definition lint, sharing-rule side', }, - { - file: 'packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts', - receiver: 'tpl', - field: 'EmailTemplate.isSystem', - why: 'copies the built-in-template marker onto the stored row', - }, { file: 'packages/plugins/plugin-security/src/explain-engine.ts', receiver: 'schema', diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 9524289c1c5..d4c915aeddb 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -603,20 +603,22 @@ const RESOLUTION_CONTROLS = [ + 'half this change reaches past when it replaces what that walk falls back TO.', }, { - file: 'packages/plugins/plugin-email/src/email-plugin.ts', - callee: 'update', - enclosing: 'upsertTemplate', - resolvesIn: 'EmailServicePlugin', + file: 'packages/services/service-analytics/src/analytics-service.ts', + callee: 'execute', + enclosing: 'answerDataset', + resolvesIn: 'FallbackDelegateStrategy', why: - 'Departure ONE — one declaration, unchanged — and this site was CHOSEN BY MEASUREMENT, after an ' - + 'ablation showed the two controls above stay green when that departure is removed. Exactly 7 ' - + 'call sites in the scan root depend on it; this is one of the four whose callee is in ' - + '`WRITE_SHAPED_CALLEES`. `update` has a single body in this file, declared as a method of an ' - + 'object literal, so the object literal is its scope and is NOT on this call site\'s ancestor ' - + 'chain — a strict lexical resolver DECLINES the hop. This control does not claim the hop is ' - + 'semantically right (the call is `(engine as any).update(...)`, whose receiver `calleePath` ' - + 'erases). It pins the INVARIANT that this repair touches only COLLISIONS: tighten past that ' - + 'and this goes red, forcing the census delta to be re-measured instead of narrowed in silence.', + 'Departure ONE — one declaration, unchanged. Re-chosen BY MEASUREMENT when the site this control ' + + 'used to pin (`EmailServicePlugin.upsertTemplate`\'s `update()` in plugin-email) was deleted with ' + + 'the template seed it belonged to. Measured on the scan root then: 21 call sites resolve through ' + + 'this departure and NONE of them has a callee in `WRITE_SHAPED_CALLEES` any more (control leg: the ' + + 'deleted plugin-email file, probed alone, answered its two write-shaped sites). Here `execute` has ' + + 'a single body in this file, a method of `FallbackDelegateStrategy`, so the class is its scope and ' + + 'is NOT on this call site\'s ancestor chain — a strict lexical resolver DECLINES the hop. This ' + + 'control does not claim the hop is semantically right (the calls are `new DatasetExecutor(…)' + + '.execute(…)`, whose receiver `calleePath` erases). It pins the INVARIANT that this repair touches ' + + 'only COLLISIONS: tighten past that and this goes red, forcing the census delta to be re-measured ' + + 'instead of narrowed in silence.', }, ]; diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 9a37c3fd650..b4983ca2c57 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -552,9 +552,6 @@ "packages/plugins/plugin-email/src/attachment-reclaim.test.ts": { "wrong": 1 }, - "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-email/src/email-headers-internal.integration.test.ts": { "blind": 1 }, @@ -567,12 +564,6 @@ "packages/plugins/plugin-email/src/email-plugin.queue-delivery.test.ts": { "wrong": 1 }, - "packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts": { - "unjudged": 1 - }, - "packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts": { "unjudged": 1 }, From 1cc2da62355ba9ad346d80fca8903136f2e8ee3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:13:44 +0000 Subject: [PATCH 12/18] chore(gates): regenerate the system-context and tenant-audit censuses on the merged tree Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 4 +- .../docs/permissions/tenant-audit-census.mdx | 38 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 22 +++++------ 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 81723f917b4..841666a7538 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -359,10 +359,10 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 128 | ✅ | +| — parsed as a property **read** | 127 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | -| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | +| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | | Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ | | — behaviour-bearing (rows 1–61 above) | 119 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index e7dc580c560..e602ffce0c8 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 226 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **226** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 231** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 226** decidable, **72** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 121 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -126 of 231 (55%) as decidably elevated, with 97 more whose elevation is a +121 of 226 (54%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 226`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | -| …whose object name is statically decidable | 156 | -| …whose object name is chosen at run time | 75 | -| …against an object with tenancy ENABLED | 155 | +| write call sites on the application surface | **226** | +| …whose object name is statically decidable | 154 | +| …whose object name is chosen at run time | 72 | +| …against an object with tenancy ENABLED | 153 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 170 | +| threading a tenant context | 165 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 126 | +| threading a decidably ELEVATED (`isSystem`) context | 121 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 187 | +| receiver carried a readable engine type | 182 | | receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 9 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 104 | +| object name spelled inline | 102 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 13 | -| object name is some other run-time expression | 62 | +| object name is an `object: string` parameter | 11 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `f6bce8aaf`. +Measured on 2026-10-11 at `87577b6a0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 65787ba01f4..d92f12d65b7 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | -| Object name statically decidable | 156 | -| Object name chosen at run time | 75 | -| Against a tenancy-enabled object | 155 | +| Write call sites | 226 | +| Object name statically decidable | 154 | +| Object name chosen at run time | 72 | +| Against a tenancy-enabled object | 153 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 170 | +| Threading a tenant context | 165 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 126 | +| Threading a decidably elevated context | 121 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `f6bce8aaf`. +Measured on 2026-10-11 at `87577b6a0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | ## Every site @@ -150,12 +150,8 @@ Measured on 2026-10-11 at `f6bce8aaf`. | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | | `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | elevated | 2 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | elevated | 1 | | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | From b263dbb9544d76a48eda786566cde822ac2b42b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:46:42 +0000 Subject: [PATCH 13/18] test(lint): pin the dead-property advisory email_template's category and isSystem now draw ADR-0131 D6/D10 retired the sys_email_template row projection, the only reader of both keys, and the ledger grades them dead. The group-C email_template case and the shipped-corpus control now assert exactly that advisory (rule id and property path), and the liveness case pins category and a truthy isSystem. The mapping row is unchanged. The retirement batch removes the advisories. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../lint/src/lint-liveness-properties.test.ts | 38 +++++++++------ .../runtime-gate.inert-type-writes.test.ts | 47 ++++++++++++++++--- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index d975e495c6b..56ad86ad4f9 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -397,21 +397,31 @@ describe('lintLivenessProperties', () => { // email_template used to carry a per-artifact warn on `name`: the WHOLE // authoring surface was disconnected from sendTemplate (the webhook shape). - // #4509 built the materializer bridge, so authoring is no longer a no-op and - // a well-formed template must warn about NOTHING. The type stays in + // #4509 built the materializer bridge, so authoring stopped being a no-op and + // a well-formed template warned about NOTHING. The type stayed in // TYPE_COLLECTIONS — a listed type with zero warns is the resolved state - // (webhook sits there the same way), and keeping it means a future - // regression that re-deadens a prop starts warning again on its own. - it('does not warn on a well-formed email_template — the bridge closed it (#4509)', () => { - const findings = lintLivenessProperties({ - emailTemplates: [{ - name: 'crm.welcome', - label: 'Welcome', - subject: 'Hi {{user.name}}', - bodyHtml: '

Welcome

', - }], - }); - expect(findings).toEqual([]); + // (webhook sits there the same way) — so that a future regression that + // re-deadens a prop starts warning again on its own. + // + // That is what happened, by design: ADR-0131 D6/D10 retired the + // `sys_email_template` row projection, the only reader of `category` and + // `isSystem`, and the ledger grades both `dead`. A template that sets neither + // still warns about nothing; each one it sets draws one dead-property + // advisory (a boolean only when truthy). #22876 retires both keys in the next + // enforce-or-remove batch, which removes these advisories with them. + it('warns on an email_template only for the keys the ledger grades dead — `category`, and `isSystem` when truthy (#22876)', () => { + const base = { name: 'crm.welcome', label: 'Welcome', subject: 'Hi {{user.name}}', bodyHtml: '

Welcome

' }; + + expect(lintLivenessProperties({ emailTemplates: [base] })).toEqual([]); + expect(lintLivenessProperties({ emailTemplates: [{ ...base, isSystem: false }] })).toEqual([]); + + const withCategory = lintLivenessProperties({ emailTemplates: [{ ...base, category: 'auth' }] }); + expect(withCategory).toHaveLength(1); + expect(ruleOf(withCategory, 'category')).toBe(LIVENESS_DEAD_PROPERTY); + + const withSystem = lintLivenessProperties({ emailTemplates: [{ ...base, isSystem: true }] }); + expect(withSystem).toHaveLength(1); + expect(ruleOf(withSystem, 'isSystem')).toBe(LIVENESS_DEAD_PROPERTY); }); // The advisory lint's job is the keys the PARSE still accepts. #4667 retired diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index ec63404d57a..20c00aaa650 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -61,7 +61,7 @@ import { describe, expect, it } from 'vitest'; import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; -import { lintLivenessProperties } from './lint-liveness-properties.js'; +import { LIVENESS_DEAD_PROPERTY, lintLivenessProperties } from './lint-liveness-properties.js'; import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; import { buildRuntimeWriteSnapshots, @@ -133,6 +133,17 @@ const ruleIds = (findings: readonly { rule: string }[]) => findings.map((f) => f const dump = (r: { errors: readonly unknown[]; advisories: readonly unknown[] }) => JSON.stringify({ errors: r.errors, advisories: r.advisories }); +/** + * An advisory's identity — its rule id and the property path it names (the + * `sets \`\`` token, the same identity `lint-liveness-properties.test.ts`'s + * `ruleOf` reads) — never its wording. + */ +const advisoryIds = (advisories: readonly unknown[]) => + (advisories as Array<{ rule?: string; message?: string }>).map((a) => ({ + rule: a.rule, + property: /sets `([^`]+)`/.exec(a.message ?? '')?.[1], + })); + // ───────────────────────────────────────────────────────────────────────────── describe('#19542 — the five wired doors are declared, mapped and reachable', () => { @@ -721,7 +732,7 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem ).toContain('lintLivenessProperties'); }); - it.each(GROUP_C)('`%s` — and the rule judges NOTHING for an item that authors no warned key', (type) => { + it('`mapping` — and the rule judges NOTHING for an item that authors no warned key', () => { // ⚠️ The report's first reading for these two, pinned rather than // remembered. The ruling dispatched the wiring and ⛔ no ledger population, // so this silence is the ruled end state. The day a property earns an @@ -730,15 +741,30 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem // `connectorSource`, until #21127 dropped it: the key is `live`, and a // warned `live` row made this rule throw (the case below pins the binding // judged silent at this door). - const item = type === 'email_template' - ? { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hi

', category: 'workflow' } - : { name: 'acme_feed', label: 'Feed', sourceFormat: 'csv', targetObject: 'acme_invoice', mode: 'upsert' }; - const result = runRuntimeAuthoringRules({ type, item, context: CONTEXT }); + const item = { name: 'acme_feed', label: 'Feed', sourceFormat: 'csv', targetObject: 'acme_invoice', mode: 'upsert' }; + const result = runRuntimeAuthoringRules({ type: 'mapping', item, context: CONTEXT }); expect(result.errors, dump(result)).toEqual([]); expect(result.advisories, dump(result)).toEqual([]); }); + it('`email_template` — the rule judges exactly the key the ledger grades dead: `category`, and nothing else', () => { + // The door lighting up with no second edit, as the case above foresaw. + // `category` went `dead` when ADR-0131 D6/D10 retired the + // `sys_email_template` row projection that was its only reader (templates + // resolve from the registry now); the liveness rule warns on a dead row by + // its verdict alone. #22876 retires the key in the next enforce-or-remove + // batch, which removes this advisory with it. Everything else this item + // authors is `live`, so the advisory is the only finding. + const item = { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hi

', category: 'workflow' }; + const result = runRuntimeAuthoringRules({ type: 'email_template', item, context: CONTEXT }); + + expect(result.errors, dump(result)).toEqual([]); + expect(advisoryIds(result.advisories), dump(result)).toEqual([ + { rule: LIVENESS_DEAD_PROPERTY, property: 'category' }, + ]); + }); + it('[#21127] a `mapping` write that DOES author `connectorSource` is judged silent — not thrown on', () => { // `connectorSource` is `live`: the connector sync executor reads every key, // so authoring it warns nothing. While its ledger row still carried @@ -800,7 +826,14 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem context: CONTEXT, }); expect(email.errors, dump(email)).toEqual([]); - expect(email.advisories, dump(email)).toEqual([]); + // Publishable: no error. One advisory, on the one key the ledger grades + // `dead` that this template sets — `category` (its only reader, the + // retired `sys_email_template` row projection, is gone; #22876 retires the + // key). `isSystem: false` is dead too and draws nothing: a boolean warns + // only when set truthy. + expect(advisoryIds(email.advisories), dump(email)).toEqual([ + { rule: LIVENESS_DEAD_PROPERTY, property: 'category' }, + ]); const mapping = runRuntimeAuthoringRules({ type: 'mapping', From 33641fe783dd5d5c95ca457638fd199326e4a8be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:48:31 +0000 Subject: [PATCH 14/18] chore(gates): regenerate the system-context and tenant-audit censuses on the merged tree Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 4 +- .../docs/permissions/tenant-audit-census.mdx | 38 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 22 +++++------ 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 92305748b2e..fc9cea8645a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -359,10 +359,10 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 129 | ✅ | +| — parsed as a property **read** | 128 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | -| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | +| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | | Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ | | — behaviour-bearing (rows 1–61 above) | 120 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1d30a19aa13..512062b712e 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 232 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 227 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **232** | +| 175 write call sites | quoted in the merged changeset | **227** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 232** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 127 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 227** decidable, **72** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -127 of 232 (55%) as decidably elevated, with 97 more whose elevation is a +122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 227`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **232** | -| …whose object name is statically decidable | 157 | -| …whose object name is chosen at run time | 75 | -| …against an object with tenancy ENABLED | 156 | +| write call sites on the application surface | **227** | +| …whose object name is statically decidable | 155 | +| …whose object name is chosen at run time | 72 | +| …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 166 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 127 | +| threading a decidably ELEVATED (`isSystem`) context | 122 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 188 | +| receiver carried a readable engine type | 183 | | receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 9 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 105 | +| object name spelled inline | 103 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 13 | -| object name is some other run-time expression | 62 | +| object name is an `object: string` parameter | 11 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `81ec7792b`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index e296a8a5c67..cf994cf85c8 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 232 | -| Object name statically decidable | 157 | -| Object name chosen at run time | 75 | -| Against a tenancy-enabled object | 156 | +| Write call sites | 227 | +| Object name statically decidable | 155 | +| Object name chosen at run time | 72 | +| Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 166 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 127 | +| Threading a decidably elevated context | 122 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `81ec7792b`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | +| tracked non-test sources scanned | 632 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 162 | +| same-named calls subtracted as non-engine | 161 | ## Every site @@ -150,12 +150,8 @@ Measured on 2026-10-11 at `5d0a4a152`. | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | | `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | elevated | 2 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | elevated | 1 | | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | From 4aa656e7a2f67d213e01a885960d281937483c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:11:59 +0000 Subject: [PATCH 15/18] =?UTF-8?q?chore(changeset):=20the=2015205=20changes?= =?UTF-8?q?et=20declares=20Clause-=E2=91=A1:=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR retires published exports and plugin options of @objectstack/plugin-email, a breaking narrowing of the package's public face; the bare 'no' named no arm. Only the declaration line changes: the BREAKING banner, the minor grade and the ADR-0087 disposition stay. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/15205-email-templates-resolve-registry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15205-email-templates-resolve-registry.md b/.changeset/15205-email-templates-resolve-registry.md index 5d3d5e9c121..fe2b842447b 100644 --- a/.changeset/15205-email-templates-resolve-registry.md +++ b/.changeset/15205-email-templates-resolve-registry.md @@ -6,7 +6,7 @@ feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire -Clause-②: no +Clause-②: no (narrowing) From 2c11a7675ec1ac40d87b4da27537bb236e367129 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:30:40 +0000 Subject: [PATCH 16/18] =?UTF-8?q?chore(changeset):=20the=2015205=20changes?= =?UTF-8?q?et=20declares=20Clause-=E2=91=A1:=20yes=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR narrows @objectstack/plugin-email's public face (nine exports and two plugin options retire) and also widens it: createRegistryEmailTemplateLoader and the type EmailTemplateRegistrySources are new names reachable from the package's single exports entry. A diff that widens one surface and narrows another is declared 'yes (narrowing)'. The minor grade and the BREAKING banner stay. The ADR-0087 marker keeps not-required (no-migration-prescription) and its why gains one sentence: the body's FROM -> TO block is a compiler-carried code change, not a metadata migration, and the disposition vocabulary has no category that fits this shape exactly, so the maintainer is asked to rule the reading. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/15205-email-templates-resolve-registry.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/15205-email-templates-resolve-registry.md b/.changeset/15205-email-templates-resolve-registry.md index fe2b842447b..a9cc040ebdf 100644 --- a/.changeset/15205-email-templates-resolve-registry.md +++ b/.changeset/15205-email-templates-resolve-registry.md @@ -6,9 +6,9 @@ feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire -Clause-②: no (narrowing) +Clause-②: yes (narrowing) - + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. From 0f1906217a83ce3381bc90bab6db5dee15d5ebf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:30:40 +0000 Subject: [PATCH 17/18] fix(spec): the email_template ledger cites objectui at the tree's pin 4997995022f8 The cross-repo evidence for label and description, and the dead notes for category and isSystem, were measured at objectui 0df67f23, which is not the .objectui-sha pin of this tree (4997995022f8 at the merge base and the head). Each cited read was re-measured at 4997995022f8e65946f6362b7434685539cc72b7 and restated there with the file:line read: ResourceListPage.tsx#defaultColumns (838 label, 839 description; the fallback at 421; the cell read at 687), default-schemas.ts#registerDefaultMetadataSchemas:336 (email_template registers no listColumns), and default-schemas.ts:310 / :324 (category and isSystem as editor inputs only). No reading changed between the two pins; no grade moves. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- packages/spec/liveness/email_template.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json index bdf8cca8fbc..bd0cd0718af 100644 --- a/packages/spec/liveness/email_template.json +++ b/packages/spec/liveness/email_template.json @@ -12,14 +12,14 @@ "label": { "status": "live", "verifiedAt": "2026-10-11", - "evidence": "objectui @0df67f23: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (the Studio metadata list's default `label` column — `email_template` registers no `listColumns` of its own, so every row of its list renders the authored label)", - "note": "Docs-shaped: shown to a human in the Studio metadata list, never by the send path. Until 2026-10-11 it was materialized into the `sys_email_template.label` column (the Setup record title); that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 0df67f23 — is the consumer now, under the designer-previews-count-as-consumers ruling (#7131).", + "evidence": "objectui @4997995022f8: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns:838 (the Studio metadata list's default `label` column, which line 421 falls back to when a type registers no `listColumns`, and whose cell line 687 reads as `row.item[c.key]`); objectui @4997995022f8: packages/app-shell/src/views/metadata-admin/default-schemas.ts#registerDefaultMetadataSchemas:336 (`email_template` is registered with a `defaultSchema` and a `fieldOrder` and no `listColumns` of its own, so every row of its list renders the authored label)", + "note": "Docs-shaped: shown to a human in the Studio metadata list, never by the send path. Until 2026-10-11 it was materialized into the `sys_email_template.label` column (the Setup record title); that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 4997995022f8 — is the consumer now, under the designer-previews-count-as-consumers ruling (#7131).", "evidenceScope": "cross-repo" }, "category": { "status": "dead", "verifiedAt": "2026-10-11", - "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): the key's only consumer was the row projection into `sys_email_template.category` (a Setup column and index), which retired with the rows. Measured: no reader in this repository (plugin-email, service-messaging, plugin-auth), and at the `.objectui-sha` pin 0df67f23 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts`), never as the Studio list facet the schema's own doc promises (`ResourceListPage.tsx#defaultColumns` renders name/label/description). Tracked for enforce-or-remove (ADR-0049). ⚠️ The schema DEFAULTS it (`'custom'`), so presence is not authorship — the mapping precedent (`errorPolicy`/`batchSize`) is why removal, not a warning, is the channel that reaches an author." + "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): the key's only consumer was the row projection into `sys_email_template.category` (a Setup column and index), which retired with the rows. Measured: no reader in this repository (plugin-email, service-messaging, plugin-auth), and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts:310`, the `email_template` form's `category` enum), never as the Studio list facet the schema's own doc promises (`ResourceListPage.tsx#defaultColumns:835` renders name/label/description). Tracked for enforce-or-remove (ADR-0049). ⚠️ The schema DEFAULTS it (`'custom'`), so presence is not authorship — the mapping precedent (`errorPolicy`/`batchSize`) is why removal, not a warning, is the channel that reaches an author." }, "locale": { "status": "live", @@ -81,13 +81,13 @@ "isSystem": { "status": "dead", "verifiedAt": "2026-10-11", - "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): its only consumers were the built-in seeder's stand-down (`EmailServicePlugin.upsertTemplate`: `if (existingRow.is_system === false) return;`) and the `sys_email_template.is_system` column, both retired with the rows. Measured: no reader in this repository, and at the `.objectui-sha` pin 0df67f23 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts`). The schema's own claim — a system template \"SHOULD NOT be deleted by tenants\" — is enforced by nothing: the built-ins are code-declared items, so the metadata door can only discard an environment item over them, whatever this key says. Tracked for enforce-or-remove (ADR-0049)." + "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): its only consumers were the built-in seeder's stand-down (`EmailServicePlugin.upsertTemplate`: `if (existingRow.is_system === false) return;`) and the `sys_email_template.is_system` column, both retired with the rows. Measured: no reader in this repository, and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts:324`, the `email_template` form's `System Template` checkbox). The schema's own claim — a system template \"SHOULD NOT be deleted by tenants\" — is enforced by nothing: the built-ins are code-declared items, so the metadata door can only discard an environment item over them, whatever this key says. Tracked for enforce-or-remove (ADR-0049)." }, "description": { "status": "live", "verifiedAt": "2026-10-11", - "evidence": "objectui @0df67f23: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (the Studio metadata list's default `description` column — `email_template` registers no `listColumns` of its own)", - "note": "Docs-shaped: read by humans in Studio, never by the send path. Until 2026-10-11 it was materialized into `sys_email_template.description`; that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 0df67f23 — is the consumer now (#7131).", + "evidence": "objectui @4997995022f8: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns:839 (the Studio metadata list's default `description` column, which line 421 falls back to when a type registers no `listColumns`, and whose cell line 687 reads as `row.item[c.key]`); objectui @4997995022f8: packages/app-shell/src/views/metadata-admin/default-schemas.ts#registerDefaultMetadataSchemas:336 (`email_template` is registered with a `defaultSchema` and a `fieldOrder` and no `listColumns` of its own)", + "note": "Docs-shaped: read by humans in Studio, never by the send path. Until 2026-10-11 it was materialized into `sys_email_template.description`; that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 4997995022f8 — is the consumer now (#7131).", "evidenceScope": "cross-repo" } } From 78c3eed6389de37493b25586bfb6c48f02c63188 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:07:45 +0000 Subject: [PATCH 18/18] chore(gates): regenerate the system-context and tenant-audit censuses on the merged tree Step 4 of scripts/pm/os-regen-merge.sh after merging origin/main 3fdb92db0, from the repo's own generators only: pnpm gen:system-context-census and node scripts/tenant-audit-census.mjs --write. The system-context figures read the merged tree (129 property reads, 5 of them unrelated metadata fields: main's 130 and 6, less the retired row projection's isSystem read); the tenant-audit page and counts restamp their measured-at commit. Spec check:generated is green. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 4 ++-- content/docs/permissions/tenant-audit-census.mdx | 2 +- docs/audits/2026-08-tenant-audit-write-call-sites.counts.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c4900f082a0..82ab7663c2d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -359,10 +359,10 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 130 | ✅ | +| — parsed as a property **read** | 129 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | -| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | +| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | | Of those reads: reads of `ExecutionContext.isSystem` | **124** | ✅ | | — behaviour-bearing (rows 1–61 above) | 121 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 512062b712e..f14126367c8 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -297,7 +297,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `81ec7792b`. +Measured on 2026-10-11 at `be55432f6`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index cf994cf85c8..9ca4d164a81 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -90,7 +90,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `81ec7792b`. +Measured on 2026-10-11 at `be55432f6`. | corpus scale (not enforced) | count | | :--- | ---: |