From faf39b00c461352fa43f5186cc8aad13f2f133e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:22:24 +0000 Subject: [PATCH 1/5] fix(service-messaging)!: refuse sys_notification_template rows for a channel other than email or sms The channel_renders_template rule becomes a cross_field invariant at severity error: a write that leaves a row naming a channel outside TEMPLATE_CHANNELS is refused, for new rows and stored rows alike. Stored rows stay readable and deletable; nothing is rewritten. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../services/service-messaging/package.json | 1 + ...mplate-channel-refusal.integration.test.ts | 264 ++++++++++++++++++ ...ation-template-channel.integration.test.ts | 196 ------------- .../objects/notification-template.object.ts | 98 ++++--- pnpm-lock.yaml | 3 + 5 files changed, 323 insertions(+), 239 deletions(-) create mode 100644 packages/services/service-messaging/src/objects/notification-template-channel-refusal.integration.test.ts delete mode 100644 packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 19d1cbeda3c..930a94b5314 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@objectstack/driver-sql": "workspace:*", "@objectstack/metadata-core": "workspace:*", + "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", "@types/node": "^26.6.3", "typescript": "^6.0.3", diff --git a/packages/services/service-messaging/src/objects/notification-template-channel-refusal.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-channel-refusal.integration.test.ts new file mode 100644 index 00000000000..b58c4c89fb6 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-template-channel-refusal.integration.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_notification_template` refuses every write that leaves a row naming a + * channel no template path reads (#24983) — the maintainer's ruling D ② on + * #24770: "② refuses stored or new non-email/sms rows", shipped now in 18.x as + * a BREAKING minor under the amended standing rule for narrowings. + * + * Only `email` and `sms` read a row (`TEMPLATE_CHANNELS`). The refusal is the + * object's `channel_renders_template` rule: a `cross_field` INVARIANT at + * `severity: 'error'`, judged over the merged record (the stored row overlaid + * with the write), so it answers new rows and stored rows alike. + * + * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3), through the + * real `ObjectStackProtocolImplementation`, the REST data door's own error + * classifier (`mapDataError`, which `POST` / `PATCH /data/:object` answer a + * thrown write with) and the import runner (`runImport`, the import commit): + * + * - NEW ROWS: a create naming another channel is refused `400 + * VALIDATION_FAILED`, the finding on `channel` (`rule_violation`) carrying + * the prescription; so is a by-id update moving a row there, an insert + * carrying an import's kept option values, and the failed row of an import + * commit. `email` and `sms` are accepted. + * - STORED ROWS (written straight through the driver — the shape a row saved + * before this rule has): any edit that leaves the channel out of the set is + * refused with the same prescription — an edit of another column, a write + * re-naming the channel, a bulk update's row and a predicate update over a + * set holding it — and the row is left exactly as stored. It is still read + * and deleted, an edit moving it to email or sms is accepted, and delivery + * is unchanged: no channel renders it, before or after. + * + * ⛔ Nothing here rewrites or deletes a stored row on the rule's behalf: the + * only deletes are the caller's own. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { runImport, type ExportFieldMeta } from '@objectstack/core'; +import { mapDataError } from '@objectstack/types'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { NotificationTemplate, TEMPLATE_CHANNELS } from './notification-template.object.js'; +import { NotificationTemplateStore, TEMPLATE_OBJECT } from '../template-renderer.js'; + +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; + +const ORG = 'org_a'; +const TOPIC = 'deal.won'; +const CTX = { isSystem: true, tenantId: ORG } as ExecutionContext; + +/** The first sentence of the refusal — what the person who saved reads. */ +const REFUSAL_HEAD = 'Only the email and sms channels use a notification template, so a template for any other channel is refused'; +const STORED_ID = 'tpl_stored_inbox'; + +const silentLogger: any = { + debug() {}, info() {}, warn() {}, error() {}, fatal() {}, + child() { return silentLogger; }, +}; + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +async function boot(): Promise<{ ql: ObjectQL; driver: SqlDriver; protocol: ObjectStackProtocolImplementation }> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const ql = new ObjectQL({ logger: silentLogger }); + ql.registerDriver(driver, true); + await ql.init(); + for (const def of [SYS_ORGANIZATION, NotificationTemplate]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => 'single'); + await ql.insert('sys_organization', { id: ORG, name: ORG }, { context: { isSystem: true } as ExecutionContext }); + engine = ql; + return { ql, driver, protocol: new ObjectStackProtocolImplementation(ql as any) }; +} + +/** Boot, plus one row stored with `channel: 'inbox'` straight through the driver. */ +async function bootWithStoredInboxRow(): Promise<{ ql: ObjectQL; protocol: ObjectStackProtocolImplementation }> { + const booted = await boot(); + await booted.driver.create(TEMPLATE_OBJECT, { id: STORED_ID, organization_id: ORG, ...row('inbox', 'stored') }); + return booted; +} + +function row(channel: string, text = channel): Record { + return { topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true }; +} + +async function storedRows(ql: ObjectQL): Promise>> { + const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['id', 'channel', 'body', 'is_active'] }, { context: CTX })) ?? []; + return rows.map((r: Record) => ({ id: r.id, channel: r.channel, body: r.body, is_active: r.is_active })); +} + +async function storedRow(ql: ObjectQL, id: string): Promise | undefined> { + return (await storedRows(ql)).find((r) => r.id === id); +} + +/** The error a write threw — and a failure when it did not throw at all. */ +async function thrownBy(write: Promise): Promise { + try { + await write; + } catch (err) { + return err; + } + throw new Error('the write was accepted; a refusal was expected'); +} + +/** The finding's prescription: its head, and in-app content as an email template. */ +function expectPrescription(message: unknown): void { + expect(String(message).startsWith(REFUSAL_HEAD)).toBe(true); + expect(String(message)).toContain('email_template'); +} + +/** + * The REST data door's answer to a thrown write (`mapDataError`, as `POST` / + * `PATCH /data/:object` call it): `400 VALIDATION_FAILED`, one finding on + * `channel` from this rule, with the prescription. + */ +function expectChannelRefusal(err: any): void { + expect(err?.code).toBe('VALIDATION_FAILED'); + const answer = mapDataError(err, TEMPLATE_OBJECT); + expect(answer.status).toBe(400); + expect(answer.body.code).toBe('VALIDATION_FAILED'); + const fields = answer.body.fields as Array>; + expect(fields).toHaveLength(1); + expect(fields[0]).toMatchObject({ field: 'channel', code: 'rule_violation' }); + expectPrescription(fields[0].message); +} + +const META_MAP = new Map( + Object.entries(NotificationTemplate.fields).map(([name, f]: [string, any]) => [name, { name, type: f.type, label: f.label }]), +); + +describe('sys_notification_template.channel: a new row for a channel that reads no row is refused', () => { + it.each(['inbox', 'push', 'webhook', 'Email', ' sms'])('a create naming %j is refused 400 VALIDATION_FAILED at the REST data door, and nothing is stored', async (channel) => { + const { ql, protocol } = await boot(); + expectChannelRefusal(await thrownBy(protocol.createData({ object: TEMPLATE_OBJECT, data: row(channel), context: CTX }))); + expect(await storedRows(ql)).toEqual([]); + }); + + it.each([...TEMPLATE_CHANNELS])('a create naming %j is stored (the positive twins)', async (channel) => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row(channel), context: CTX }); + expect(created.record).toMatchObject({ channel }); + expect((await storedRows(ql)).map((r) => r.channel)).toEqual([channel]); + }); + + it('an insert carrying an import\'s kept option values is refused all the same', async () => { + const { ql } = await boot(); + const importCtx = { ...CTX, keptOptionValues: { channel: ['inbox'] } } as ExecutionContext; + expectChannelRefusal(await thrownBy(ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx }))); + expect(await storedRows(ql)).toEqual([]); + }); + + it('a by-id update moving an email row to another channel is refused, and the row keeps email', async () => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('email'), context: CTX }); + expectChannelRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: String(created.id), data: { channel: 'inbox' }, context: CTX }))); + expect(await storedRow(ql, String(created.id))).toMatchObject({ channel: 'email' }); + }); + + it('a by-id update between email and sms is accepted', async () => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('email'), context: CTX }); + await protocol.updateData({ object: TEMPLATE_OBJECT, id: String(created.id), data: { channel: 'sms', body: 'moved' }, context: CTX }); + expect(await storedRow(ql, String(created.id))).toMatchObject({ channel: 'sms', body: 'moved' }); + }); + + it('an import commit stores the sms row and fails the inbox row on channel, with the prescription', async () => { + const { ql, protocol } = await boot(); + const summary = await runImport({ + p: protocol as any, + objectName: TEMPLATE_OBJECT, + context: CTX, + rows: [row('sms'), row('inbox')], + metaMap: META_MAP, + writeMode: 'insert', + matchFields: [], + dryRun: false, + runAutomations: false, + trimWhitespace: true, + createMissingOptions: false, + skipBlankMatchKey: false, + }); + expect(summary).toMatchObject({ created: 1, errors: 1 }); + expect(summary.results[0]).toMatchObject({ row: 1, ok: true, action: 'created' }); + expect(summary.results[1]).toMatchObject({ row: 2, ok: false, action: 'failed', field: 'channel', code: 'rule_violation' }); + expectPrescription(summary.results[1].error); + expect((await storedRows(ql)).map((r) => r.channel)).toEqual(['sms']); + }); +}); + +describe('sys_notification_template.channel: a row already stored with another channel', () => { + it('is refused on an edit of another column at the REST data door, and is left as stored', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + expectChannelRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { body: 'edited body' }, context: CTX }))); + expectChannelRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { is_active: false }, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toEqual({ id: STORED_ID, channel: 'inbox', body: 'stored body', is_active: true }); + }); + + it('is refused on a write that names its channel again', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + expectChannelRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { channel: 'inbox', body: 'x' }, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ channel: 'inbox', body: 'stored body' }); + }); + + it('fails its own row of a bulk update, VALIDATION_FAILED / 400, while the email row lands', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + const email = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('email'), context: CTX }); + const answer = await protocol.updateManyData({ + object: TEMPLATE_OBJECT, + records: [{ id: STORED_ID, data: { body: 'bulk' } }, { id: String(email.id), data: { body: 'bulk' } }], + options: { continueOnError: true }, + context: CTX, + } as any); + expect(answer).toMatchObject({ success: false, total: 2, succeeded: 1, failed: 1 }); + const [inboxResult, emailResult] = answer.results as Array>; + expect(inboxResult).toMatchObject({ id: STORED_ID, success: false }); + expect(inboxResult.errors).toHaveLength(1); + expect(inboxResult.errors[0]).toMatchObject({ code: 'VALIDATION_FAILED', httpStatus: 400 }); + expectPrescription(inboxResult.errors[0].message); + expect(emailResult).toMatchObject({ id: String(email.id), success: true }); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ channel: 'inbox', body: 'stored body' }); + expect(await storedRow(ql, String(email.id))).toMatchObject({ channel: 'email', body: 'bulk' }); + }); + + it('refuses a predicate update over a set holding it, and that update writes no row', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + const email = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('email'), context: CTX }); + expectChannelRefusal(await thrownBy(ql.update(TEMPLATE_OBJECT, { is_active: false }, { where: { topic: TOPIC }, multi: true, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ is_active: true }); + expect(await storedRow(ql, String(email.id))).toMatchObject({ is_active: true }); + }); + + it('is accepted on an edit that moves it to email, and edits normally from then on', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + await protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { channel: 'email' }, context: CTX }); + await protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { body: 'repaired body' }, context: CTX }); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ channel: 'email', body: 'repaired body' }); + }); + + it('is still read and deleted', async () => { + const { ql, protocol } = await bootWithStoredInboxRow(); + const read = await protocol.getData({ object: TEMPLATE_OBJECT, id: STORED_ID, context: CTX } as any); + expect(read.record).toMatchObject({ id: STORED_ID, channel: 'inbox' }); + await protocol.deleteData({ object: TEMPLATE_OBJECT, id: STORED_ID, context: CTX } as any); + expect(await storedRows(ql)).toEqual([]); + }); + + it('still renders on no channel', async () => { + const { ql } = await bootWithStoredInboxRow(); + // Control: the row is really there for this topic and organization. + expect(await storedRow(ql, STORED_ID)).toBeDefined(); + const store = new NotificationTemplateStore({ getData: () => ql as any }); + for (const channel of TEMPLATE_CHANNELS) { + expect(await store.load(TOPIC, channel, 'en', ORG)).toBeNull(); + } + }); +}); diff --git a/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts deleted file mode 100644 index b9acedb21b9..00000000000 --- a/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `sys_notification_template.channel` names a channel that reads a row, or the - * write is WARNED about: the 18.x half of the maintainer's ruling D on #24770 - * (card #24924). - * - * Only `email` and `sms` read a row (the email channel's inline path, the SMS - * channel, and plugin-auth's phone OTP / invitation SMS); the inbox reads none - * on any path. A row naming any other channel used to be stored and then - * rendered nowhere, silently. The ruling refuses such rows, but no ADR-0087 - * conversion carries stored DATA rows, so in 18.x the write is ACCEPTED with a - * warning and the refusal waits for 19.0. ⛔ Nothing here asserts a refusal. - * - * The warning is the object's `channel_renders_template` rule at - * `severity: 'warning'` — objectql's advisory path. One hit is handed to the - * write's own `onValidationAdvisory` listener (the data protocol answers it as - * the single-record write answer's `warnings`) and logged once per write. - * - * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3), through every - * engine write shape the rule validator runs on: - * - * - WARNING: an insert, an insert carrying an import's kept option values, a - * by-id update and a predicate (`multi`) update that name another channel - * are accepted, the row is stored as written, and exactly one advisory names - * `channel_renders_template` / `channel` with the message head. - * - SILENCE: `email` and `sms` writes produce no advisory (the positive twins, - * so no warning pin passes against a rule that warns about everything). - * - STORED ROWS: a row already stored with another channel (written under - * the rule's absence, straight through the driver) is read, edited in any - * other column and deleted with no advisory, and still renders on no - * channel. A write that names its channel again is warned about, and - * accepted. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { ValidationAdvisoryEvent } from '@objectstack/spec/data'; -import { NotificationTemplate } from './notification-template.object.js'; -import { NotificationTemplateStore, TEMPLATE_OBJECT } from '../template-renderer.js'; - -const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; - -const ORG = 'org_a'; -const TOPIC = 'deal.won'; -const CTX = { isSystem: true, tenantId: ORG } as ExecutionContext; - -const RULE = 'channel_renders_template'; -/** The first sentence of the warning — what the person who saved reads. */ -const WARNING_HEAD = 'This channel never uses a notification template'; - -let engine: ObjectQL | undefined; - -afterEach(async () => { - try { await engine?.destroy(); } catch { /* noop */ } - engine = undefined; -}); - -async function boot(): Promise<{ ql: ObjectQL; driver: SqlDriver }> { - const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); - const ql = new ObjectQL(); - ql.registerDriver(driver, true); - await ql.init(); - for (const def of [SYS_ORGANIZATION, NotificationTemplate]) { - ql.registry.registerObject(def as any, '@objectstack/service-messaging'); - } - await ql.syncSchemas(); - ql.setTenancyPostureProvider(() => 'single'); - await ql.insert('sys_organization', { id: ORG, name: ORG }, { context: { isSystem: true } as ExecutionContext }); - engine = ql; - return { ql, driver }; -} - -function row(channel: string, text = channel): Record { - return { topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true }; -} - -async function channelsStored(ql: ObjectQL): Promise { - const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['channel'] }, { context: CTX })) ?? []; - return rows.map((r: Record) => String(r.channel)).sort(); -} - -/** A fresh listener for ONE write: the advisories that write reported. */ -function advisories(): { events: ValidationAdvisoryEvent[]; onValidationAdvisory: (e: ValidationAdvisoryEvent) => void } { - const events: ValidationAdvisoryEvent[] = []; - return { events, onValidationAdvisory: (e) => { events.push(e); } }; -} - -/** Exactly one advisory, and it is this rule's, on `channel`, with the warning's head and prescription. */ -function expectOneChannelWarning(events: readonly ValidationAdvisoryEvent[]): void { - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ rule: RULE, severity: 'warning', field: 'channel', code: 'invalid_format' }); - expect(String(events[0].message).startsWith(WARNING_HEAD)).toBe(true); - // The prescription: in-app content belongs to an email template, named by - // a notify step's `template`. - expect(String(events[0].message)).toContain('email_template'); -} - -describe('sys_notification_template.channel: a channel that reads no row is accepted with a warning', () => { - it.each(['email', 'sms'])('an insert naming %j is stored with no advisory (the positive twins)', async (channel) => { - const { ql } = await boot(); - const seen = advisories(); - await ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expect(seen.events).toEqual([]); - expect(await channelsStored(ql)).toEqual([channel]); - }); - - it.each(['inbox', 'push', 'webhook', 'Email', ' sms'])('an insert naming %j is stored, with one channel advisory', async (channel) => { - const { ql } = await boot(); - const seen = advisories(); - await ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expectOneChannelWarning(seen.events); - expect(await channelsStored(ql)).toEqual([channel]); - }); - - it('an insert carrying an import\'s kept option values is stored, with one channel advisory', async () => { - const { ql } = await boot(); - const seen = advisories(); - const importCtx = { ...CTX, keptOptionValues: { channel: ['inbox'] } } as ExecutionContext; - await ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx, onValidationAdvisory: seen.onValidationAdvisory }); - expectOneChannelWarning(seen.events); - expect(await channelsStored(ql)).toEqual(['inbox']); - }); - - it('a by-id update moving a row to another channel is stored, with one channel advisory', async () => { - const { ql } = await boot(); - const created = await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - const seen = advisories(); - await ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { id: created.id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expectOneChannelWarning(seen.events); - expect(await channelsStored(ql)).toEqual(['inbox']); - }); - - it('a predicate update moving a row to another channel is stored, with one channel advisory', async () => { - const { ql } = await boot(); - await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - const seen = advisories(); - await ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { topic: TOPIC }, multi: true, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expectOneChannelWarning(seen.events); - expect(await channelsStored(ql)).toEqual(['inbox']); - }); - - it('an update of an email row that does not move its channel produces no advisory', async () => { - const { ql } = await boot(); - const created = await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - const seen = advisories(); - await ql.update(TEMPLATE_OBJECT, { channel: 'sms', body: 'moved' }, { where: { id: created.id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expect(seen.events).toEqual([]); - expect(await channelsStored(ql)).toEqual(['sms']); - }); - - describe('a row already stored with another channel', () => { - /** Written straight through the driver: the shape a row authored before the rule has. */ - async function bootWithStoredInboxRow(): Promise<{ ql: ObjectQL; id: string }> { - const booted = await boot(); - const id = 'tpl_stored_inbox'; - await booted.driver.create(TEMPLATE_OBJECT, { id, organization_id: ORG, ...row('inbox', 'stored') }); - return { ql: booted.ql, id }; - } - - it('is read and edited in every other column as before, with no advisory', async () => { - const { ql, id } = await bootWithStoredInboxRow(); - const seen = advisories(); - await ql.update(TEMPLATE_OBJECT, { body: 'edited body', is_active: false }, { where: { id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expect(seen.events).toEqual([]); - const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); - expect(stored).toMatchObject({ id, channel: 'inbox', body: 'edited body', is_active: false }); - }); - - it('still renders on no channel', async () => { - const { ql, id } = await bootWithStoredInboxRow(); - // Control: the row is really there for this topic and organization. - expect((await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX })).length).toBe(1); - const store = new NotificationTemplateStore({ getData: () => ql as any }); - expect(await store.load(TOPIC, 'email', 'en', ORG)).toBeNull(); - expect(await store.load(TOPIC, 'sms', 'en', ORG)).toBeNull(); - }); - - it('is deleted as before', async () => { - const { ql, id } = await bootWithStoredInboxRow(); - await ql.delete(TEMPLATE_OBJECT, { where: { id }, context: CTX }); - expect(await channelsStored(ql)).toEqual([]); - }); - - it('a write that names its channel again is stored, with one channel advisory', async () => { - const { ql, id } = await bootWithStoredInboxRow(); - const seen = advisories(); - await ql.update(TEMPLATE_OBJECT, { channel: 'inbox', body: 'x' }, { where: { id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); - expectOneChannelWarning(seen.events); - const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); - expect(stored).toMatchObject({ channel: 'inbox', body: 'x' }); - }); - }); -}); diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 84c52411f6b..9675ee5837b 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -4,9 +4,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * The channels that READ a `sys_notification_template` row (the maintainer's - * ruling D on #24770: narrow templates to the channels that render them). In - * 18.x a write naming any other channel is accepted with a warning — see the - * `channel_renders_template` rule below; its refusal waits for 19.0. + * ruling D on #24770: narrow templates to the channels that render them). A + * write that leaves a row naming any other channel is refused — see the + * `channel_renders_template` rule below, which is built from this list and is + * its only reader on the write path. * * Measured, not assumed — the readers are exactly three, all keyed on one of * these two values: @@ -22,10 +23,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * The inbox reads no row on any path (`createInboxChannel` takes no store); its * localised content is the `email_template` a notify step names in `template`, * rendered through `IEmailService.renderTemplate`. So a row naming any other - * channel is stored and rendered nowhere — a silent no-op until this rule made - * it loud. + * channel would be stored and rendered nowhere — a silent no-op, which is why + * the rule refuses it. + * + * Exported for this package's own tests, which iterate it rather than spell the + * two values again; it is not on the package index. */ -const TEMPLATE_CHANNELS = ['email', 'sms'] as const; +export const TEMPLATE_CHANNELS = ['email', 'sms'] as const; /** * `sys_notification_template` — the email / SMS render template (ADR-0030 @@ -107,11 +111,11 @@ export const NotificationTemplate = ObjectSchema.create({ // `MessagingChannel.id`s, 64 per the landed machine-vocabulary // precedent (sys_session.revoke_reason, maxLength: 64). maxLength: 64, - // [#24924] The values a row is read for are TEMPLATE_CHANNELS; any - // other value is warned about by the `channel_renders_template` - // rule below. The field stays `text` on purpose — see that rule for - // why a `select` is not the spelling. - description: 'Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in a notify step\'s template.', + // [#24983] The values a row is read for are TEMPLATE_CHANNELS; a + // write that leaves any other value on the row is refused by the + // `channel_renders_template` rule below. The field stays `text` on + // purpose — see that rule for why a `select` is not the spelling. + description: 'Which channel uses this template: email or sms; any other value is refused. The in-app inbox never does; for in-app content, write an email template and name it in a notify step\'s template.', }), locale: Field.text({ @@ -173,46 +177,54 @@ export const NotificationTemplate = ObjectSchema.create({ { fields: ['topic'] }, ], - // [#24924] The WARNING half of ruling D for 18.x. The ruling record reads - // "② refuses stored or new non-email/sms rows. If no stored-row - // conversion carries that, 18.x warns on write and corrects the copy, and - // the refusal is filed for 19.0." — and no ADR-0087 conversion carries - // stored DATA rows, so under the standing rule for narrowings after 18.0 - // GA this rule warns and never refuses. ⛔ Do not raise it to `error` in - // 18.x; the refusal is a 19.0 card. + // [#24983] Ruling D ② on #24770: "② refuses stored or new non-email/sms + // rows." Under the amended standing rule for narrowings after 18.0 GA, a + // narrowing no conversion carries ships now as a BREAKING minor — and no + // ADR-0087 conversion reaches stored DATA rows of a platform object — so the + // refusal is this rule, at `severity: 'error'`, with no stored row rewritten. + // + // WHY `cross_field` AND NOT A `format` RULE. The ruling refuses STORED rows + // as well as new ones, and the two spellings answer a stored row + // differently (measured on a real ObjectQL over SqlDriver): // - // `severity: 'warning'` is objectql's advisory path: the write goes - // through, the engine logs one `Validation rule 'channel_renders_template' - // (warning): …` line per write and hands the hit to that write's - // `onValidationAdvisory` listener, which the data protocol answers as the - // single-record create / clone / update answer's `warnings`. The rule runs - // on insert, by-id update and bulk update. + // - A `format` rule judges the WRITE: it reads `channel` only when the + // payload names it (`checkFormat` returns early otherwise). An edit of a + // stored `inbox` row's body or `is_active` — which is what Setup's edit + // form sends, since it writes only the fields the user changed — would be + // accepted, and the row would stay stored and rendered nowhere, silently. + // - A `cross_field` (or `script`) rule is an INVARIANT: objectql evaluates + // its condition over the MERGED record (the stored row overlaid with the + // write) on insert, by-id update and per row of a predicate update. So a + // row naming another channel is refused on ANY edit that leaves it there, + // until an edit moves it to email or sms — frozen, not bricked: it is + // still read, listed and deleted (a delete runs no validation rule), and + // delivery is unchanged (no channel ever read it). `fields: ['channel']` + // attaches the refusal to `channel`, so an import row and the console + // point at that column. // - // A `format` rule judges the WRITE, never the stored row: it reads only a - // payload that names `channel` (`checkFormat` returns early otherwise), so - // a row already stored with another channel is read, listed, edited in any - // other column and deleted without a warning — and keeps rendering - // nowhere, as it always did. A write that NAMES another channel (a new - // row, a change of channel, or a client re-sending the stored value) is - // warned about. + // Why not a `select` with options `email` / `sms` (measured at this + // commit): its refusal is the generic catalog sentence ("Channel must be one + // of: email, sms", code `invalid_option`) with no per-field override to + // carry the email-template prescription; an insert carrying an import's + // kept option values admits `inbox`; it moves the column from `varchar(64)` + // to `varchar(255)`; and it judges only the written value, so a stored row + // passes every edit that does not name `channel`. // - // Why not a `select` with options `email` / `sms`: option membership is a - // field CONSTRAINT in objectql's record validator, which refuses and has - // no advisory severity, so it is the 19.0 refusal and not the 18.x - // warning. (For 19.0, measured at this commit: its refusal is the generic - // catalog sentence with no per-field override; an import's kept option - // values admit a value outside the options; and Setup's select widget - // clears on load a stored value its options do not offer.) + // The refusal is a `400 VALIDATION_FAILED` at the REST data door, a failed + // row (`field: 'channel'`, `code: 'rule_violation'`) in an import commit and + // a failed `VALIDATION_FAILED` row in a bulk update. validations: [ { - type: 'format', + type: 'cross_field', name: 'channel_renders_template', label: 'Channel renders the template', description: 'Only the email and SMS channels read a notification template, so a template for any other channel would never be sent.', - field: 'channel', - regex: `^(?:${TEMPLATE_CHANNELS.join('|')})$`, - severity: 'warning', - message: 'This channel never uses a notification template: only email and sms do, so this template will never be sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step\'s template; email and the inbox both render it.', + fields: ['channel'], + // TRUE ⇒ violated. Built from TEMPLATE_CHANNELS, so the list is + // spelled once: a CEL list of the two string literals. + condition: `!(record.channel in ${JSON.stringify(TEMPLATE_CHANNELS)})`, + severity: 'error', + message: 'Only the email and sms channels use a notification template, so a template for any other channel is refused: set channel to email or sms, or delete the template. For in-app (inbox) content, write an email template (email_template) and name it in the notify step\'s template; email and the inbox both render it.', }, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6136050a3b..b0ba10e6ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2829,6 +2829,9 @@ importers: '@objectstack/metadata-core': specifier: workspace:* version: link:../../metadata-core + '@objectstack/metadata-protocol': + specifier: workspace:* + version: link:../../metadata-protocol '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From 6630d4baeee2f2e1dd7358b10cac28e04bc179ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:24:21 +0000 Subject: [PATCH 2/5] chore(service-messaging): changeset and translated copy for the template channel refusal Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24983-template-channel-refusal.md | 18 ++++++++++++++++++ .../translations/es-ES.objects.generated.ts | 4 ++-- .../translations/ja-JP.objects.generated.ts | 4 ++-- .../translations/zh-CN.objects.generated.ts | 4 ++-- 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 .changeset/24983-template-channel-refusal.md diff --git a/.changeset/24983-template-channel-refusal.md b/.changeset/24983-template-channel-refusal.md new file mode 100644 index 00000000000..e2ff1c13ff5 --- /dev/null +++ b/.changeset/24983-template-channel-refusal.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-messaging': minor +--- + +fix(service-messaging)!: `sys_notification_template` refuses a write that leaves a row naming a channel other than `email` or `sms`, because no channel ever sends such a row + +A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row for `inbox`, `push` or any other value was stored and then rendered nowhere; the `channel_renders_template` rule only warned about it. That rule now refuses. It is an invariant judged over the stored row overlaid with the write, so it answers rows already stored with another channel as well as new ones: such a row is refused on any edit that leaves its channel out of the set, until an edit moves it to `email` or `sms`, and it is still read, listed and deleted. No stored row is rewritten or deleted by this change. + +Clause-②: no (narrowing) + + + +**BREAKING** (a platform object's write accept set narrows, stored rows included), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row naming a channel other than `email` or `sms` was accepted and stored, with at most a `channel_renders_template` advisory in the write answer's `warnings`. A row already stored with such a channel took every edit. +- **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding on `channel` (`code: 'rule_violation'`); an import commit fails that row with `field: 'channel'` and `code: 'rule_violation'`; a bulk update fails that row with `code: 'VALIDATION_FAILED'` and `httpStatus: 400`, and a predicate update over a set holding such a row writes no row. Every refusal carries the prescription: set the channel to `email` or `sms`, or delete the template; in-app content is an email template (`email_template`) named in a notify step's `template`. +- **Unchanged:** `email` and `sms` rows and every write to them; reading, listing and deleting any row; how every channel renders, so a row for another channel is still sent by none; and the `channel` column and its width. +- **The fix for an operator:** a row for another channel was never sent. Delete it, or set its channel to `email` or `sms` if its text belongs there. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index 48f64af8bf6..cc6efe07a91 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -296,7 +296,7 @@ export const esESObjects: NonNullable = { }, channel: { label: "Channel", - help: "Qué canal usa esta plantilla: email o sms. La bandeja de entrada de la aplicación nunca la usa; para el contenido de la aplicación, escribe una plantilla de correo electrónico e indícala en el template de un paso de notificación." + help: "Qué canal usa esta plantilla: email o sms; cualquier otro valor se rechaza. La bandeja de entrada de la aplicación nunca la usa; para el contenido de la aplicación, escribe una plantilla de correo electrónico e indícala en el template de un paso de notificación." }, locale: { label: "Locale", @@ -344,7 +344,7 @@ export const esESObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "Este canal nunca usa una plantilla de notificación: solo email y sms la usan, así que esta plantilla nunca se enviará. Para el contenido de la aplicación (bandeja de entrada), escribe una plantilla de correo electrónico (email_template) e indícala en el template del paso de notificación; el correo electrónico y la bandeja de entrada la muestran." + message: "Solo los canales email y sms usan una plantilla de notificación, así que se rechaza una plantilla para cualquier otro canal: establece el canal en email o sms, o elimina la plantilla. Para el contenido de la aplicación (bandeja de entrada), escribe una plantilla de correo electrónico (email_template) e indícala en el template del paso de notificación; el correo electrónico y la bandeja de entrada la muestran." } } }, diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index f6a4b98ee8d..fa36b260d27 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -296,7 +296,7 @@ export const jaJPObjects: NonNullable = { }, channel: { label: "Channel", - help: "このテンプレートを使用するチャネル:email または sms。アプリ内の受信トレイは使用しません。アプリ内の内容は、メールテンプレートを作成して通知ステップの template で指定してください。" + help: "このテンプレートを使用するチャネル:email または sms。その他の値は拒否されます。アプリ内の受信トレイは使用しません。アプリ内の内容は、メールテンプレートを作成して通知ステップの template で指定してください。" }, locale: { label: "Locale", @@ -344,7 +344,7 @@ export const jaJPObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "このチャネルは通知テンプレートを使用しません。使用するのは email と sms だけなので、このテンプレートは送信されません。アプリ内(受信トレイ)の内容は、メールテンプレート(email_template)を作成して通知ステップの template で指定してください。メールと受信トレイの両方がそれを表示します。" + message: "通知テンプレートを使用するのは email と sms のチャネルだけなので、その他のチャネルのテンプレートは拒否されます。チャネルを email または sms に設定するか、テンプレートを削除してください。アプリ内(受信トレイ)の内容は、メールテンプレート(email_template)を作成して通知ステップの template で指定してください。メールと受信トレイの両方がそれを表示します。" } } }, diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 7c5d86345b5..863c41447a3 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -296,7 +296,7 @@ export const zhCNObjects: NonNullable = { }, channel: { label: "通道", - help: "使用本模板的通道:email 或 sms。站内信从不使用;站内信内容请编写邮件模板,并在通知步骤的 template 中指定它。" + help: "使用本模板的通道:email 或 sms,其他值会被拒绝。站内信从不使用;站内信内容请编写邮件模板,并在通知步骤的 template 中指定它。" }, locale: { label: "语言", @@ -344,7 +344,7 @@ export const zhCNObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "该通道从不使用通知模板:只有 email 和 sms 使用,因此此模板永远不会被发送。站内信内容请编写邮件模板(email_template),并在通知步骤的 template 中指定它;邮件和站内信都会渲染它。" + message: "只有 email 和 sms 通道使用通知模板,因此其他通道的模板会被拒绝:请将通道设为 email 或 sms,或删除该模板。站内信内容请编写邮件模板(email_template),并在通知步骤的 template 中指定它;邮件和站内信都会渲染它。" } } }, From 4474a067ccafa58755cdb923a851395f005f9005 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:27:21 +0000 Subject: [PATCH 3/5] chore(service-messaging): regenerate the en bundle for the template channel refusal copy Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/translations/en.objects.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index 692178fea29..d17ff184a8e 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -296,7 +296,7 @@ export const enObjects: NonNullable = { }, channel: { label: "Channel", - help: "Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in a notify step's template." + help: "Which channel uses this template: email or sms; any other value is refused. The in-app inbox never does; for in-app content, write an email template and name it in a notify step's template." }, locale: { label: "Locale", @@ -344,7 +344,7 @@ export const enObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "This channel never uses a notification template: only email and sms do, so this template will never be sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step's template; email and the inbox both render it." + message: "Only the email and sms channels use a notification template, so a template for any other channel is refused: set channel to email or sms, or delete the template. For in-app (inbox) content, write an email template (email_template) and name it in the notify step's template; email and the inbox both render it." } } }, From 5be509465204d2b09f2d28cfa5418cc1c3d639c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:28:57 +0000 Subject: [PATCH 4/5] test(service-messaging): read metadata-protocol from source in this package's tests Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- packages/services/service-messaging/vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/services/service-messaging/vitest.config.ts b/packages/services/service-messaging/vitest.config.ts index fe92a78a42b..ba363d19f5e 100644 --- a/packages/services/service-messaging/vitest.config.ts +++ b/packages/services/service-messaging/vitest.config.ts @@ -30,6 +30,10 @@ export default defineConfig({ resolve: { alias: [ { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + // [#24983] The template channel refusal pins drive the real data + // protocol (create / update / bulk update / the import runner's writes), + // so it is read from source for the same reason as core above. + { find: /^@objectstack\/metadata-protocol$/, replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts') }, ], }, }); From 6974906eba3c466411790992a90f7349febba7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:10:11 +0000 Subject: [PATCH 5/5] docs(changeset): correct the pending template channel note that the refusal makes false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 24924 entry described a warning that the 24983 refusal supersedes in the same release; its headline, the warning paragraph, its Clause-② line and its Unchanged sentence now describe what ships. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24924-template-channel-email-sms.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/24924-template-channel-email-sms.md b/.changeset/24924-template-channel-email-sms.md index aaae6749614..f2d1037f3d9 100644 --- a/.changeset/24924-template-channel-email-sms.md +++ b/.changeset/24924-template-channel-email-sms.md @@ -2,13 +2,13 @@ '@objectstack/service-messaging': patch --- -fix(service-messaging): a `sys_notification_template` saved for a channel other than `email` or `sms` is now warned about, because no channel ever sends it +fix(service-messaging): the `sys_notification_template` copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so. -The object now carries a `channel_renders_template` rule at `severity: 'warning'`. A write naming another channel is still accepted and stored, and it now reports the warning. A single-record create, clone or update answers it in the write answer's `warnings` (`rule`, `severity`, `field: 'channel'`, `code`, `message`), and the server logs it once per write. Bulk updates and imports log it. The message points in-app content to an email template (`email_template`) named in a notify step's `template`. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does. +This entry first added a `channel_renders_template` warning for such a write; in the same release #24983's refusal superseded it, so a write naming another channel is refused rather than warned about, as that entry describes. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does. -Clause-②: no — nothing a write could send before is refused now; a save naming another channel gains one advisory in the write answer's existing `warnings`. +Clause-②: no — this entry now describes copy only (the object's description, its empty state and the field help); the narrowing of what a write may send is #24983's, declared in its own entry. -- **Unchanged.** Every write that was accepted is still accepted, and stored rows are not read, rewritten or flagged by anything but a write that names their channel. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged. +- **Unchanged.** Nothing this entry describes changes which writes are accepted. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged. - **What to do.** For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. A row for another channel was never sent, so delete it if you no longer want it listed.