Skip to content

Commit 8878c36

Browse files
fix(service-messaging): Notification Preferences says which topics and channels can be entered (#25449)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuRnMeoGgdWq8MSdroEhKis3)_ Fixes #25346 Clause-②: no Metadata-protocol: none Claim of record: `6365308855` on the card (card thread, `domain:services` seat 1). Ruling of record: `6365616409` (letter **A**, Sales, on the needs_decision in os-dev-report `6365345478`). Branch `claude/project-thread-8hqiya` is the harness-pinned branch for this session. It names no issue, so the Duplicate Fix Guard's branch-name hint will not match, and the claim is the identity record. ## What changes Setup → Notification Preferences showed Topic and Channel as bare text boxes. Their help only said "Notification topic" and "Channel id (inbox/email/push/…)", and no `push` channel exists. The card asked for pickers wherever the platform can enumerate the values. The measurement in the report found that neither field can be a picker without a contract change: - **Topics.** A topic is whatever string the sender writes, and nothing registers topics. - **Channels.** Channels are registered at runtime through an open seam: `MessagingChannel` is exported, and `registerChannel` is public. A `select` does not fit: - it is closed on the write path; - its option values are `SystemIdentifierSchema`, so the stored `*` wildcard cannot be an option. Ruling A keeps both fields as `text` and makes the help say what to type. `packages/services/service-messaging/src/objects/notification-preference.object.ts`: - **Channel help:** `inbox` (always available), `email` (once an email service is configured), `sms` (once an SMS service is configured), or the id of a channel an installed plugin adds. Enter `*` for all channels. An id no channel uses matches nothing. `push` is gone. - **Topic help:** the topic a flow's notify step names, or one the platform sends, such as `approval.requested` or `collab.mention`. Enter `*` for all topics. A topic nothing sends matches nothing. The type, default (`*`), `maxLength`, index and resolver are unchanged. No stored row changes meaning and no write is newly refused. The comments beside both declarations record why the field is not a picker. Translation bundles: - `en` was regenerated by `node scripts/check-i18n-bundles.mjs --write`. - `zh-CN`, `es-ES` and `ja-JP` were translated by hand. The es-ES and ja-JP leaves were byte copies of the old English source, which `check:i18n-stale-fill` reds on. Re-translating them dropped their two entries from each `*.source-hashes.generated.ts`, as the tool writes it. ## Pins New file `packages/services/service-messaging/src/objects/notification-preference-topic-channel-help.test.ts`, 3 cases: - **Both fields stay free text.** Each is `text` with default `*`, and its help says `Enter * for all`. - **The channel help names exactly the built-in channels.** The pin compares it with the `id`s read from the three channel factories this package builds (`createInboxChannel`, `createEmailChannel`, `createSmsChannel`). So a channel added or renamed here fails until the help says so. The help names none of the spec vocabulary's ids that no channel implements (`push`, `webhook`, `slack`, `teams`, `in-app`), and it says a plugin's own channel is valid. - **The topic help says where a topic comes from.** It names the notify step and an example topic. **Ablation leg:** the object file was restored from `f0fdd45b` (base) and the pin file run: 3 failed. HEAD: 3 passed. The file was restored with `git checkout HEAD --`, and `git diff --quiet HEAD` passed. No `dist/` leg is needed, because the pin imports source. ## Tests and gates Read on `2d1b522b`. Then `main` was merged in, giving head `ad7975f4`. None of the 7 incoming commits touches a `service-messaging` path, so nothing was re-run. - **Package tests:** `pnpm --filter @objectstack/service-messaging test`: `Test Files 61 passed (61)`, `Tests 729 passed (729)`. - **Type check and lint:** `pnpm --filter @objectstack/service-messaging typecheck`: exit 0. `eslint` on the two changed source files: exit 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 68 commands, and all 68 exit 0. Three needed a prerequisite first: - `check-plugin-teardown-shape --self-test` ran after its pinned fixture commit was fetched; - `check:dual-build-cjs-loads` ran after the 8 dists it named were built; - `check:i18n-stale-fill` ran after the es-ES and ja-JP re-translation. - **`--ran` reconcile:** `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. - **Not run locally, declared to CI:** the type-check lanes and the path-scheduled CI jobs. No dogfood battery is owed: there is no `!` in the title and no `(narrowing)` arm. ## Acceptance notes - The card's "Done when" asks for pickers wherever the platform can enumerate the values. Per the ruling, neither field gets one. A real dropdown over an open vocabulary would need a spec-lane suggestion list on text fields (the report's option C). That is not part of this card and is not filed by it. - The help also says a plugin's own channel id is accepted, and that a value nothing uses matches nothing. Both describe today's behaviour, within ruling A. --- _Generated by [Claude Code](https://claude.ai/code/session_01GsN1y3qWPdkVLasbhDB2Fy)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GsN1y3qWPdkVLasbhDB2Fy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2c0752 commit 8878c36

9 files changed

Lines changed: 124 additions & 14 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging): Setup's Notification Preferences form says which topics and channels can be entered
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Topic and Channel boxes on a notification preference only said "Notification topic" and "Channel id (inbox/email/push/…)", so an administrator had to know the machine names. One of the channels it named, `push`, does not exist. Both fields stay free text, because neither has a fixed list to pick from:
11+
12+
- **Topics.** A topic is whatever name the sender uses, and nothing registers topics. A flow's notify step names its own topic, and platform features send fixed ones such as `approval.requested`.
13+
- **Channels.** Channels are registered while the server runs, and any plugin can add one. A dropdown would accept only the values in its list, and it cannot store the `*` wildcard that means "all channels".
14+
15+
The help now names the values:
16+
17+
- **Channel:** `inbox` (always available), `email` (once an email service is configured), `sms` (once an SMS service is configured), or the id of a channel an installed plugin adds.
18+
- **Topic:** where topics come from, with examples.
19+
20+
Both helps also say `*` means all, and that a value nothing uses matches nothing.
21+
22+
No stored row, accepted value, default or column changes. The translation bundles carry the new help.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_notification_preference.topic` and `.channel` stay free text, and their
5+
* help says what to type (#25346, ruling A 2026-10-23).
6+
*
7+
* Setup → Notification Preferences offered two bare text boxes, so muting a
8+
* topic meant knowing its machine name. Neither set can be a picker: topics
9+
* are whatever string a sender writes (nothing registers them), and channels
10+
* are registered at runtime through an open seam while a `select` is closed on
11+
* the write path and cannot spell the stored `'*'` wildcard. The help carries
12+
* the vocabulary instead, and these pins keep it honest:
13+
*
14+
* - both fields stay `text` with the `'*'` default the resolver reads as
15+
* "all", so no stored row or write changes meaning;
16+
* - the channel help names exactly the ids of the channels this package
17+
* builds, read from the channel factories themselves, so a channel added
18+
* or renamed here fails this file until the help says so (the old help
19+
* named a `push` channel that never existed);
20+
* - the topic help names where topics come from and the wildcard.
21+
*/
22+
23+
import { describe, expect, it } from 'vitest';
24+
import { NotificationPreference } from './notification-preference.object.js';
25+
import { createInboxChannel } from '../inbox-channel.js';
26+
import { createEmailChannel } from '../email-channel.js';
27+
import { createSmsChannel } from '../sms-channel.js';
28+
import { NotificationTemplateStore } from '../template-renderer.js';
29+
30+
const fields = NotificationPreference.fields as Record<string, { type?: string; defaultValue?: unknown; description?: string }>;
31+
32+
/** The ids of the channels `MessagingServicePlugin` registers, read off the factories. */
33+
function builtInChannelIds(): string[] {
34+
const getData = () => undefined;
35+
const store = new NotificationTemplateStore({ getData });
36+
return [
37+
createInboxChannel({ getData }).id,
38+
createEmailChannel({ getEmail: () => undefined, getData, store }).id,
39+
createSmsChannel({ getSms: () => undefined, getData, store }).id,
40+
];
41+
}
42+
43+
/** Lowercase words of a help text that read as a channel id (`inbox`, `email`, …). */
44+
function channelIdsNamedIn(help: string, candidates: readonly string[]): string[] {
45+
return candidates.filter((id) => new RegExp(`(^|[^a-z_])${id}([^a-z_]|$)`).test(help));
46+
}
47+
48+
describe('sys_notification_preference topic / channel help (#25346)', () => {
49+
it('both stay free text defaulting to the * wildcard', () => {
50+
for (const name of ['topic', 'channel']) {
51+
expect(fields[name]?.type, name).toBe('text');
52+
expect(fields[name]?.defaultValue, name).toBe('*');
53+
expect(fields[name]?.description, name).toMatch(/Enter \* for all /);
54+
}
55+
});
56+
57+
it('the channel help names every channel this package builds, and no channel it does not', () => {
58+
const help = fields.channel?.description ?? '';
59+
const ids = builtInChannelIds();
60+
expect(ids).toEqual(['inbox', 'email', 'sms']);
61+
expect(channelIdsNamedIn(help, ids)).toEqual(ids);
62+
// Ids the spec's channel vocabulary carries that no channel here implements.
63+
expect(channelIdsNamedIn(help, ['push', 'webhook', 'slack', 'teams', 'in-app'])).toEqual([]);
64+
// The seam is open: a plugin's own channel id is a valid value too.
65+
expect(help).toMatch(/a channel an installed plugin adds/);
66+
});
67+
68+
it('the topic help says where a topic comes from', () => {
69+
const help = fields.topic?.description ?? '';
70+
expect(help).toMatch(/notify step/);
71+
expect(help).toMatch(/approval\.requested/);
72+
});
73+
});

‎packages/services/service-messaging/src/objects/notification-preference.object.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,16 @@ export const NotificationPreference = ObjectSchema.create({
133133
// stored topic could never match an event the platform can store.
134134
// '*' is 1 char.
135135
maxLength: 200,
136-
description: "Notification topic, or '*' for all topics.",
136+
// [#25346, ruling A 2026-10-23] Free text, not a picker: no list of
137+
// topics exists to pick from. A topic is whatever string the sender
138+
// writes — a flow's notify step names its own, and platform features
139+
// send fixed ones (`approval.*` in plugin-approvals, `collab.mention`
140+
// in plugin-audit, …) — and nothing registers them. So the help says
141+
// where a topic comes from instead. `notification-preference-
142+
// topic-channel-help.test.ts` pins both helps.
143+
description:
144+
'The topic the notifications are sent under: the topic a flow\'s notify step names, or one the platform sends, ' +
145+
'such as approval.requested or collab.mention. Enter * for all topics. A topic nothing sends matches nothing.',
137146
}),
138147

139148
channel: Field.text({
@@ -147,7 +156,17 @@ export const NotificationPreference = ObjectSchema.create({
147156
// precedent (sys_session.revoke_reason, maxLength: 64). '*' is
148157
// 1 char.
149158
maxLength: 64,
150-
description: "Channel id (inbox/email/push/…), or '*' for all channels.",
159+
// [#25346, ruling A 2026-10-23] Free text, not a picker. The channels
160+
// are known only at runtime, and the seam is open (any plugin may
161+
// `registerChannel` its own id), while a `select` is closed on the
162+
// write path and its option values are machine identifiers, which
163+
// cannot spell the stored '*' wildcard. So the help names the ids
164+
// this package registers instead; the pin holds that list equal to
165+
// the `id`s of the three channels it builds (no `push` exists).
166+
description:
167+
'The channel: inbox (notifications shown inside the app, always available), email (once an email service is configured), ' +
168+
'sms (once an SMS service is configured), or the id of a channel an installed plugin adds. ' +
169+
'Enter * for all channels. An id no channel uses matches nothing.',
151170
}),
152171

153172
enabled: Field.boolean({

‎packages/services/service-messaging/src/translations/en.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
210210
},
211211
topic: {
212212
label: "Topic",
213-
help: "Notification topic, or '*' for all topics."
213+
help: "The topic the notifications are sent under: the topic a flow's notify step names, or one the platform sends, such as approval.requested or collab.mention. Enter * for all topics. A topic nothing sends matches nothing."
214214
},
215215
channel: {
216216
label: "Channel",
217-
help: "Channel id (inbox/email/push/…), or '*' for all channels."
217+
help: "The channel: inbox (notifications shown inside the app, always available), email (once an email service is configured), sms (once an SMS service is configured), or the id of a channel an installed plugin adds. Enter * for all channels. An id no channel uses matches nothing."
218218
},
219219
enabled: {
220220
label: "Enabled",

‎packages/services/service-messaging/src/translations/es-ES.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
210210
},
211211
topic: {
212212
label: "Topic",
213-
help: "Notification topic, or '*' for all topics."
213+
help: "El tema con el que se envían las notificaciones: el tema que indica el paso de notificación de un flujo, o uno que envía la plataforma, como approval.requested o collab.mention. Escriba * para todos los temas. Un tema que nadie envía no coincide con nada."
214214
},
215215
channel: {
216216
label: "Channel",
217-
help: "Channel id (inbox/email/push/…), or '*' for all channels."
217+
help: "El canal: inbox (notificaciones dentro de la aplicación, siempre disponible), email (una vez configurado un servicio de correo electrónico), sms (una vez configurado un servicio de SMS) o el id de un canal que añada un plugin instalado. Escriba * para todos los canales. Un id que ningún canal usa no coincide con nada."
218218
},
219219
enabled: {
220220
label: "Enabled",

‎packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
9595
"objects.sys_notification_delivery.label": "dab3eb6594ba3014",
9696
"objects.sys_notification_delivery.pluralLabel": "654247db5abbd7d2",
9797
"objects.sys_notification_preference.description": "48556455d3292290",
98-
"objects.sys_notification_preference.fields.channel.help": "8c06b7c3f1a65fd7",
9998
"objects.sys_notification_preference.fields.channel.label": "979883fe73ee88cb",
10099
"objects.sys_notification_preference.fields.created_at.label": "1f02d416befb595b",
101100
"objects.sys_notification_preference.fields.digest.label": "76e9b35f27134f0b",
@@ -107,7 +106,6 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
107106
"objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f",
108107
"objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833",
109108
"objects.sys_notification_preference.fields.quiet_hours.label": "fab43f24806d8f6f",
110-
"objects.sys_notification_preference.fields.topic.help": "cf04f6b5ad2a5a33",
111109
"objects.sys_notification_preference.fields.topic.label": "819afdb3853e9d80",
112110
"objects.sys_notification_preference.fields.updated_at.label": "aba63dc2a9c79b8d",
113111
"objects.sys_notification_preference.fields.user_id.label": "6c0d22a931e235bd",

‎packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
210210
},
211211
topic: {
212212
label: "Topic",
213-
help: "Notification topic, or '*' for all topics."
213+
help: "通知の送信に使われるトピック:フローの通知ステップが指定するトピック、またはプラットフォームが送信するトピック(例: approval.requested、collab.mention)。すべてのトピックを対象にするには * を入力します。どこからも送信されないトピックは何にも一致しません。"
214214
},
215215
channel: {
216216
label: "Channel",
217-
help: "Channel id (inbox/email/push/…), or '*' for all channels."
217+
help: "チャネル:inbox(アプリ内の通知、常に利用可能)、email(メールサービスの設定後)、sms(SMS サービスの設定後)、またはインストール済みプラグインが追加したチャネルの ID。すべてのチャネルを対象にするには * を入力します。どのチャネルも使用しない ID は何にも一致しません。"
218218
},
219219
enabled: {
220220
label: "Enabled",

‎packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
9494
"objects.sys_notification_delivery.label": "dab3eb6594ba3014",
9595
"objects.sys_notification_delivery.pluralLabel": "654247db5abbd7d2",
9696
"objects.sys_notification_preference.description": "48556455d3292290",
97-
"objects.sys_notification_preference.fields.channel.help": "8c06b7c3f1a65fd7",
9897
"objects.sys_notification_preference.fields.channel.label": "979883fe73ee88cb",
9998
"objects.sys_notification_preference.fields.created_at.label": "1f02d416befb595b",
10099
"objects.sys_notification_preference.fields.digest.label": "76e9b35f27134f0b",
@@ -106,7 +105,6 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
106105
"objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f",
107106
"objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833",
108107
"objects.sys_notification_preference.fields.quiet_hours.label": "fab43f24806d8f6f",
109-
"objects.sys_notification_preference.fields.topic.help": "cf04f6b5ad2a5a33",
110108
"objects.sys_notification_preference.fields.topic.label": "819afdb3853e9d80",
111109
"objects.sys_notification_preference.fields.updated_at.label": "aba63dc2a9c79b8d",
112110
"objects.sys_notification_preference.fields.user_id.label": "6c0d22a931e235bd",

‎packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
210210
},
211211
topic: {
212212
label: "主题",
213-
help: "通知主题,'*' 表示所有主题。"
213+
help: "通知发送时使用的主题:流程中通知步骤指定的主题,或平台功能发出的主题,例如 approval.requested、collab.mention。填 * 表示所有主题。没有任何发送方使用的主题不会匹配任何通知。"
214214
},
215215
channel: {
216216
label: "通道",
217-
help: "通道 ID(inbox/email/push/…),'*' 表示所有通道。"
217+
help: "通道:inbox(站内收件箱,始终可用)、email(配置邮件服务后可用)、sms(配置短信服务后可用),或已安装插件添加的通道 ID。填 * 表示所有通道。没有任何通道使用的 ID 不会匹配任何通知。"
218218
},
219219
enabled: {
220220
label: "启用",

0 commit comments

Comments
 (0)