You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-messaging: sys_notification_template takes only the channels that read it (email, sms), by the maintainer's ruling D on #24770 #24924
Filing gate ③ a task the maintainer set: the execution card ② of the maintainer's ruling D on #24770 (「决策箱 三张同意」, recorded by triage in 6293376907). Filed by the domain:services seat 2 (seat post #21118, os-support-ai, session session_01UmqzD97e14iws6hmVhgZ5y). Labels: domain:services and area:workflow follow the parent card. priority:p2 is inherited from #24770, and triage may regrade.
Reader (who acts): the domain:services seat claims and dispatches it, after the PR of #24865 lands: both work on the template reads, and this card edits the object that card reads.
What to do (from the decision 6290342354, 裁后执行 for D)
sys_notification_template.channel becomes a closed set: email and sms, the two channels that read the row. The inbox reads no template row on any path.
Today another value is stored and renders nowhere, silently. A save that names another channel is answered loudly with a message that points to the email template (email_template, named by the notify node's template) for in-app content.
Correct the object's description and empty-state copy to say which channels render a row, and that a notify node naming a template uses that email template on every channel.
Correct the two stale texts:
content/docs/permissions/authentication.mdx:446-451: "built-in English and Chinese rows are seeded once … and can be changed under Setup" is stale since the seed retired.
The script.config.template tombstone (packages/spec/src/automation/schemaless-node-config.zod.ts:353-358) and its regenerated reference: they predate notify's own template key. packages/spec is the spec lane's file, declared cross-lane in the claim.
The card states whether a conversion carries the narrowing. Rows already stored with another channel keep rendering nowhere, as today.
If no stored-row conversion carries the refusal of those rows, 18.x answers a new write with a warning and corrects the copy, and the refusal is filed for 19.0 by the implementing PR.
Readings it builds on
The readings-only report on #24770 (6290284887), sections R1a–R1c, on origin/main9a06f0be0. Re-measure on current main first.
Dedupe
The parent card #24770 is the only record. Execution card ① (subscriptions retire) is filed alongside it, as the sibling of this card. #24865 (template reads not organization-scoped) is related, not a duplicate. New.
This card edits sys_notification_template, and #24865 (in flight with this seat, claim 6292807780) changes how that table is read. It waits for #24865 to close. The machine line Blocked-by: #24865 is in the body.
When this card corrects content/docs/permissions/authentication.mdx's "SMS text customisation & localisation" paragraph (the stale seed sentence), it also states what PR #24930 (#24865) made true:
the OTP and invitation SMS bodies read a sys_notification_template row in the organization the send resolves to, tenancy.defaultOrgId();
under single, that is the Default Organization's row;
under isolated and group, no row is read and the built-in text is sent.
The paragraph today calls the bodies "tenant-customisable", which is no longer true under a walled posture. Recorded in the seat's REWORK 6296720561 on PR #24930. Blocked-by: #24865 stands.
The only blocker, #24865, closed when PR #24930 landed as 865d2c8cf4 (template reads are now keyed by organization). The seat re-derived the blockers: no other Blocked-by: applies. The scope addition in this thread (the authentication.mdx SMS paragraph) stands.
Claim: PM loop round 3 · 2026-10-20T18:55Z
Session: session_01UmqzD97e14iws6hmVhgZ5y
Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24924-template-channel-email-sms
Worktree: objectstack-issue-24924
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface:
packages/services/service-messaging/src/objects/notification-template.object.ts (the channel options, the description and the empty-state copy) and the write path that refuses another channel, inside service-messaging;
its generated translation bundles;
cross-lane, declared here: content/docs/permissions/authentication.mdx, the "SMS text customisation & localisation" paragraph only (domain:devx; the stale seed sentence, and what PR fix(service-messaging): resolve a notification template in the recipient's organization #24930 made true: the auth SMS row is read in tenancy.defaultOrgId(), the Default Organization under single, and under isolated / group the built-in text is sent);
pins beside each; one .changeset/24924-*.md.
⛔ Not packages/spec/**: the script.config.template tombstone half moved to #24965 (spec lane). ⛔ Not template-renderer.ts or the channels (PR #24930 landed them). Stop on breach and explain in the report.
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface, derived at 2941a1929: no path-derived mandate, floor sonnet, default opus, ceiling fable)
Clause-②: no (expected: the channel accept set narrows). The narrowing owes a contract-review-tier review before the queue.
Responsibility: sys_notification_template accepts channels no reader renders, so a row saved with one renders nowhere | the email and sms channels read it; notify's own template key names an email template | every Setup admin authoring templates (card, ruling D)
Thread-read: 6298141670
Serial constraints cleared: #24865 (Blocked-by) closed with PR #24930 as 865d2c8cf4. No open PR touches service-messaging/src/objects or authentication.mdx (open PRs read this round).
Under the maintainer's ruling D on #24770 (6293376907) and the standing rule 甲 (#60156292005854): the card states whether a conversion carries the narrowing. If no stored-row conversion carries the refusal, 18.x warns on write and corrects the copy, and the refusal is filed for 19.0.
os-dev-report
{
"issue": 24924,
"status": "done",
"branch": "claude/issue-24924-template-channel-email-sms",
"pr": "#24976",
"session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent, shared with the dispatching PM)",
"premise_still_valid": true,
"summary": "sys_notification_template.channel now accepts only email and sms. The object declares a format validation rule, channel_renders_template, whose pattern comes from one TEMPLATE_CHANNELS constant. A save naming another channel answers VALIDATION_FAILED, served 400, attributed to channel, and the message points in-app content to an email template named in a notify step's template. The rule guards insert, by-id update, predicate update and an import's keptOptionValues; stored rows with another channel are untouched except by a save that names that channel. The object description, empty state and channel/subject help say which channels use a row; they are regenerated into the four bundles and translated in zh-CN, ja-JP and es-ES. The authentication.mdx SMS paragraph drops the retired seed and states the defaultOrgId reading PR #24930 made true. Release reading under rule 甲: no ADR-0087 conversion carries data rows, and the measured shape is the dispatch's first arm (new writes refused loudly, stored rows untouched), so this ships in 18.x and no 19.0 refusal card is owed. The changeset is minor, because the changeset gate requires minor for no (narrowing).",
"tests": "All at head 6c9eed4 unless stated otherwise. (1) New pin src/objects/notification-template-channel.integration.test.ts, 13 cases on a real ObjectQL with SqlDriver over better-sqlite3. Refusals: insert naming inbox, push, webhook, Email and ' sms'; insert with keptOptionValues; by-id update; predicate update; re-save of a stored row's own channel. Each asserts resolveThrownHttpError(err).code VALIDATION_FAILED and .status 400, fields[0] {field: channel, code: invalid_format}, the message head 'Channel must be email or sms', and that it contains email_template. Positive twins: email and sms accepted. Stored-row preservation: edit of other columns, delete, and NotificationTemplateStore.load returning null for both email and sms. (2) Ablation through scripts/ablation-replace.mjs, anchor regex line x1 to x0, replacement '[\s\S]*' x0 to x1, blob 629a45203037 to 04d520ea64d9. Result 'Tests 9 failed | 4 passed (13)', every refusal red with 'AssertionError: the write was accepted: expected undefined to be defined', the 4 preservation cases green. Restore: blob 629a45203037 == HEAD, git diff HEAD empty. The object is imported from source, so no rebuild was needed for the mutation to reach the test. (3) pnpm --filter @objectstack/service-messaging run typecheck: VERDICT command-exit 0; tsc --listFiles contains the new test once. (4) pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2: 'Test Files 54 passed (54) / Tests 606 passed (606)', VERDICT command-exit 0. (5) Import side, because the exported object narrowed: plugin-auth phone-sms-template-organization.integration.test.ts and phone-sms-seed-retired.test.ts gave 'Test Files 2 passed / Tests 7 passed', after a turbo build of the plugin-auth closure (27 of 28 cached). (6) Scratch measurement of the select alternative, deleted and never committed. Insert inbox was refused with the generic 'Channel must be one of: email, sms' (invalid_option). Insert inbox with keptOptionValues was ACCEPTED. channel: null on a stored inbox row was refused 'Channel is required and cannot be cleared'. The select column is varchar(255) where the text column is varchar(64).",
"gates": "Local subset: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, derives 98 commands at 6c9eed4 (10 paths against merge base 5d27d32). Each ran with its exit code captured before any pipe; results are in scratchpad issue-24924/gates-results.tsv. 96 exited 0 on the first run. pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: no dist for client-react and 9 others). After a turbo workspace build (76 tasks, 75 cached) both exit 0. Reconciliation: dispatch-gates --ran with each line recorded as command :: exit code reads 'Run reconciliation — 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN' and '0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)'. Named among the 98, all exit 0: check-adr-0087-registration --base origin/main ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', not-required (no-migration-prescription)), check-changeset-no-major --base origin/main, check:i18n, check:i18n-stale-fill ('0 stale-fill leaf/leaves'), check-keyed-text-bounds, check:nul-bytes, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias and check:type-check-coverage. Extra: check-changeset-no-major --event with a simulated PR body passes the level axis ('direction arm: narrowing — a BREAKING change; during the launch window it ships minor'). Not run locally: check-platform-checklist --changed-from (no exported symbol renamed or deleted), test:repo (owned by CI), check:dev-prereqs and check:skill-frame-freshness (neither the packages/spec build nor the four-axis framework files are touched). CI on PR #24976 head 6c9eed4 at report time: 25 check-runs, 22 in_progress, 2 skipped, 1 success. Governed Surface Queue Guard and Check Changeset were in_progress. NOT MEASURED: CI conclusions, because the report was not deferred for CI.",
"mcp_calls": "0",
"api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs through POST /repos/objectstack-ai/objectstack/dispatches). (1) pr_create, run 39488211748: POST /repos/objectstack-ai/objectstack/pulls (draft #24976; the read-back says 7572 bytes sent and 7572 stored, identical) plus POST /repos//issues/24976/assignees (os-support-ai). (2) This os-dev-report comment, posted with scripts/pm/post-stamped.mjs: POST /repos//issues/24924/comments. Separately, git push of the branch, which is not a REST write. No labels were written: the dispatch named none and a changeset exists. Reads otherwise: REST GET on issues/24924 and its comments, comments 6290284887, 6290342354, 6293376907 and 6292005854, pulls/24976, and commits/6c9eed45b/check-runs.",
"line_budget": "+331 / -33 across 10 files against merge base 5d27d32. Hand-written: notification-template.object.ts +84/-6; the new pin test +177; authentication.mdx +16/-5; changeset +18. Generated or translated bundles: en, zh-CN, ja-JP and es-ES objects +9/-4 each; es-ES and ja-JP source-hashes -3 each. The PR stays under the 3,000-line queue threshold, and no governed surface is touched (Tier: none).",
"files_changed": [
".changeset/24924-template-channel-email-sms.md",
"content/docs/permissions/authentication.mdx",
"packages/services/service-messaging/src/objects/notification-template.object.ts",
"packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts",
"packages/services/service-messaging/src/translations/en.objects.generated.ts",
"packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts"
],
"deviations": [
"Route, from the suggested-route section. The write path is the object's own format validation rule, not select options plus the platform's select validation. I measured the select spelling on this branch, and it fails three ways. Its refusal can only be the generic catalog sentence ('Channel must be one of: email, sms'): the catalog is keyed by message key, with no per-field override, so it cannot carry the email-template pointer the card requires. An import's keptOptionValues makes it ACCEPT inbox. And, by source reading at the console pin 616df6598, objectui's SelectField clears an out-of-set stored value on load (packages/fields/src/widgets/SelectField.tsx cascade clear; packages/core/src/evaluator/optionRules.ts isValueStillOffered), so a Setup edit of any column of a stored inbox row would send channel: null. That write is measured refused as required: a stored-row path newly refused, which would push the refusal to 19.0 under rule 甲. The format rule refuses on every engine write path, carries the prescription, and leaves stored rows and the varchar(64) column untouched. The field stays Field.text. The objectui half is a source reading, not a browser run.",
"The changeset is minor, not the patch the dispatch expected. check-changeset-no-major grades a declared 'Clause-②: no (narrowing)' as breaking, and minor is the launch-window grade (measured). The changeset carries the ADR-0087 marker not-required (no-migration-prescription) and BREAKING FROM/TO bullets, in the shape of the precedent changesets 24813, 24765 and 24707.",
"Copy. The dispatch and card asked the copy to say that a notify node naming a template uses that email template on every channel. That is measured false for SMS: sms-channel.ts never reads payload.template and always loads the (topic, sms, locale) row, and with no row the SMS body is the topic, because notify-node.ts sets no title or body on the template path. The shipped copy says that email and the inbox render the named email template and that SMS still uses its row here. The PR body has a section on this.",
"Scope, within the declared file: the subject field's help also changed. It dropped 'inbox title' and now says what SMS does with the subject. Same object copy, same defect (it promised the inbox); ruling D's option text named it.",
"origin/main advanced 3 commits (5f11cec, 693352e, 6440cf1) after the branch merged 5d27d32 and before pr_create. Those commits touch cli, plugin-security and docs/qa, disjoint from this diff. I did not merge again, to avoid re-running the 98-gate union for a disjoint change; the PR body states this.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By. The repo rule and its pre-push hook take precedence.",
"One scratch measurement test was created inside the worktree for the select reading, then deleted before any commit; git status was clean afterwards."
],
"pr_body_edits": [],
"open_questions": [],
"out_of_scope_findings": [
"carrier: domain:services seat 2, the #24770 family · noted, not filed · The SMS channel ignores a notify template. sms-channel.ts has no payload.template branch, so a notify naming an email template with channels including sms sends the (topic, sms, locale) row if one exists, otherwise the topic string, because notify-node.ts sets no title or body on the template path. NotifyConfigSchema.template's describe ('renders subject/body from that member') does not say it is email and inbox only. No reach reading: no measured producer sends a templated notify on sms (HotCRM's 19 templated notify nodes are inbox and email; the examples name no template). Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.",
"carrier: none · noted, not filed, Acceptance notes only · objectui SelectField at pin 616df6598 clears a stored value its options do not offer when the form loads. On a non-cascading select, an edit form would then save null for a value the user never touched. Source reading only, with no browser measurement and no reach reading. It blocks a future select spelling of this channel field."
]
}
Seat correction, and REWORK of PR #24976 (patch round 1) · domain:services seat 2 (seat post #21118) · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T20:09Z
The seat's error. The dispatch put the seat's own reading into its binding release clause: "If the object's options and write path refuse a new non-email/sms row loudly, and stored rows are untouched, the narrowing of NEW writes is a fix and ships in 18.x." No ruling carries that.
What governs. The ruling record (6293376907) 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." This card's own Release section says the same.
os-dev-report
{
"issue": 24924,
"status": "done",
"branch": "claude/issue-24924-template-channel-email-sms",
"pr": "#24976",
"session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent, shared with the dispatching PM)",
"round": "REWORK patch round 1 (card comment 6300407976); head c7102b3",
"premise_still_valid": true,
"summary": "18.x warning half of ruling D. channel_renders_template keeps its pattern from TEMPLATE_CHANNELS and now carries severity 'warning', with a warning's wording. A write naming a channel other than email or sms is accepted and stored, and it reports exactly one advisory: rule channel_renders_template, severity warning, field channel, code invalid_format, plus the message. The advisory reaches the write's onValidationAdvisory listener and is logged once per write. Single-record create and update answers carry it in their existing warnings member; bulk-update and import-commit answers do not (it reaches the log only). No surface was added. Nothing is refused, and no pin asserts a refusal. TEMPLATE_CHANNELS, the corrected copy, the authentication.mdx paragraph and the SMS copy correction are kept. The changeset is patch with 'Clause-②: no' plus a one-line reason, which check-changeset-no-major measures clean.",
"tests": "All at head c7102b3. (1) Pin src/objects/notification-template-channel.integration.test.ts, 15 cases on a real ObjectQL with SqlDriver over better-sqlite3. Nine cases each check that the write is accepted, the row is stored as written, and the write's own listener gets exactly one advisory {rule: channel_renders_template, severity: warning, field: channel, code: invalid_format} whose message starts 'This channel never uses a notification template' and contains email_template. Those nine are: an insert naming inbox, push, webhook, Email or ' sms'; an insert carrying keptOptionValues; a by-id update; a predicate update; a re-save of a stored row's own channel. Silence: email and sms inserts, and an email-to-sms update, produce an empty advisory list. Stored-row preservation: an edit of other columns produces no advisory, the row can be deleted, and NotificationTemplateStore.load is null for both email and sms. No refusal assertion remains: a grep of the test for refus, toThrow and rejects hits only the docblock prose. (2) Ablation, rule inert (pattern to '[\s\S]*' via scripts/ablation-replace.mjs; anchor x1 to x0; blob 84c52411f6b9 to ffb658f4350e): 'Tests 9 failed | 6 passed (15)', each red case 'AssertionError: expected [] to have a length of 1 but got +0'; restored blob 84c52411f6b9 == HEAD, git diff HEAD empty. (3) Inverse ablation, rule warns on everything (pattern '^(?!)$'; blob to e218b3a5764d): 'Tests 3 failed | 12 passed (15)', the three silence cases red with 'expected [ { …(5) } ] to deeply equal []'; restored blob == HEAD 84c52411f6b9. (4) pnpm --filter @objectstack/service-messaging run typecheck and the full suite under the lock: VERDICT command-exit 0, 'Test Files 54 passed (54) / Tests 608 passed (608)'. (5) The object moved, so plugin-auth's phone-sms-template-organization.integration.test.ts and phone-sms-seed-retired.test.ts were re-run after a turbo build of the plugin-auth closure (27 of 28 cached): 'Test Files 2 passed / Tests 7 passed'. (6) Scratch measurement through the real ObjectStackProtocolImplementation (absolute dist import; deleted, never committed). createData inbox answered warnings [{rule: channel_renders_template, severity: warning, field: channel, code: invalid_format, message}] and stored channel inbox. updateData email to push answered the same warnings. createData email answered no warnings key, and updateData of the body only on the push row answered no warnings key. insertManyData webhook (the import commit path) answered outcomes [{ok: true}] with no warnings. updateManyData to chat answered success with no warnings. Stored channels were chat, inbox, webhook; there was one log line per non-email/sms write (4), each 'Validation rule 'channel_renders_template' (warning): This channel never uses…'. REST crud-routes.ts relays the createData/updateData answers whole (source reading). The console's display of warnings was not measured.",
"gates": "Local subset at c7102b3. dispatch-gates --commands --repo objectstack-ai/objectstack, no paths, derives 98 commands, the same set as round 1. All 98 exit 0, each exit captured before any pipe (scratchpad issue-24924/gates-results2.tsv). This includes check-adr-0087-registration --base origin/main ('this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'), check-changeset-no-major --base origin/main, check:i18n, check:i18n-stale-fill, check-keyed-text-bounds, check:nul-bytes, check:skill-examples and check:dual-build-cjs-loads; the last two passed after a turbo workspace build, 76 tasks with 52 cached. The --ran reconciliation reads 'Run reconciliation — 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN' and '0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)'. Changeset level with the new declaration, as the coordinator asked: check-changeset-no-major --base origin/main --event, with a simulated body whose Clause-② line is the pr_body_edits one, exits 0. It reads '✓ LEVEL AXIS: this PR declares clause-② no, so no package here is declared to have grown a published surface' and 'direction arm: none declared', with '@objectstack/service-messaging': patch. Not merged: origin/main 6440cf1 does not conflict (git merge-tree --write-tree exit 0). PR #24976 check-runs at c7102b3: 42 runs, 35 success, 6 skipped, 1 failure. Required: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core and Governed Surface Queue Guard are all success. Check Changeset is failure: it reads the stale PR body line 'Clause-②: no (narrowing)', which pr_body_edits replaces; the changeset was not raised to minor.",
"mcp_calls": "0",
"api_writes": "1 this round: the os-dev-report comment on #24924 through scripts/pm/post-stamped.mjs (fleet relay; POST /repos//issues/24924/comments). Also git push of c7102b3 to claude/issue-24924-template-channel-email-sms, which is not a REST write. No PR body edit: the replacement is in pr_body_edits. No labels. Reads: REST GET on comments 6300407976 and issues/24924/comments, and commits/c7102b32f/check-runs.",
"line_budget": "Branch: +350 / -33 across 10 files against merge base 5d27d32. This round: +133 / -114 across 7 files against 6c9eed4. Per file against the merge base: notification-template.object.ts +88/-6; pin test +196; authentication.mdx +16/-5; changeset +14; en/zh-CN/ja-JP/es-ES objects bundles +9/-4 each; es-ES and ja-JP source-hashes -3 each. Under the 3,000-line queue threshold; no governed surface (Tier: none).",
"files_changed": [
".changeset/24924-template-channel-email-sms.md",
"content/docs/permissions/authentication.mdx",
"packages/services/service-messaging/src/objects/notification-template.object.ts",
"packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts",
"packages/services/service-messaging/src/translations/en.objects.generated.ts",
"packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts"
],
"deviations": [
"The PM's mechanism assumption holds. A non-error rule is an advisory, the write goes through, there is one per-write log line, and the hit goes to the write's onValidationAdvisory listener. One nuance was measured: only single-record createData, cloneData and updateData answer it to the caller (existing warnings member, relayed whole by REST create/update). insertManyData (import commit) and updateManyData (bulk) answer no warnings, so there the warning is the log line only. Reported plainly; no surface added.",
"The format rule stays text-plus-rule rather than a select. The reason is now that select option membership is a refusing field constraint with no advisory severity, so a select would be the 19.0 refusal. Round 1's select measurements are kept in the object comment and the PR body for whoever builds 19.0.",
"An inverse ablation (pattern '^(?!)$', the rule warns on everything) was added beside the requested one, so the silence pins are also shown able to fail.",
"The pin test grows from 13 to 15 cases. It adds an email-to-sms update with no advisory and splits the positive twins per channel.",
"The worktree /home/user/objectstack-issue-24924 was recreated with git worktree add on the existing local branch claude/issue-24924-template-channel-email-sms (head 6c9eed4 == origin). After this round's report it is removed again (node_modules first, then git worktree remove without --force).",
"Commit trailers use AGENTS.md's model-free pair, as in round 1."
],
"pr_body_edits": [
{
"pull": 24976,
"edit": "replace the whole body with the text below; line 1 stays 'Fixes #24924', line 3 is the new Clause-② declaration at line start",
"body": "Fixes #24924\n\nClause-②: 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.\n\n## What changes\n\nThis is the 18.x half of execution card ② of the maintainer's ruling D on #24770. sys_notification_template.channel names a channel that reads a template row (email or sms), or the write is warned about. Patch round 1 replaced the refusal this PR first shipped: the seat's correction is on the card, comment 6300407976.\n\n- Write warning. The object declares one format validation rule, channel_renders_template, at severity: 'warning'. Its pattern is built from a single TEMPLATE_CHANNELS constant (email, sms). A write naming any other channel is accepted and stored, and it reports one advisory. The advisory carries rule: 'channel_renders_template', severity: 'warning', field: 'channel', code: 'invalid_format', and this 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."\n- Copy. The object description, the all view's empty state, and the channel and subject help now say which channels use a row and that the inbox never does. They are translated in zh-CN, ja-JP and es-ES, as is the warning. The es-ES and ja-JP leaves for the changed keys were untranslated English fills; they are translations now, so their provenance hashes drop.\n- Docs. In content/docs/permissions/authentication.mdx, the "SMS text customisation & localisation" paragraph loses the retired-seed sentence. It now states what PR #24930 made true: the auth SMS row is read in tenancy.defaultOrgId(). Under single that is the Default Organization's row. Under isolated and group no row is read and the built-in text is sent.\n- Not touched:packages/spec/** (the script.config.template tombstone half belongs to #24965 in the spec lane), template-renderer.ts, and the channels.\n\n## Release reading (standing rule 甲)\n\nThe ruling record (6293376907) 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."\n\n- No ADR-0087 conversion carries this narrowing, because the rows are stored data of a platform object, not authored metadata.\n- So 18.x ships the warning half and the corrected copy.\n- The refusal of stored and new rows is filed for 19.0, by the seat.\n- Nothing in this PR refuses a write, and no pin asserts a refusal.\n\n## Where the warning reaches (measured)\n\nMeasured on a real ObjectQL with SqlDriver at this branch; the protocol half is a scratch test that is not committed.\n\n| Write | Accepted and stored | What the caller is answered | Server log |\n|---|---|---|---|\n| single-record create (createData) | yes | warnings with the hit | one line |\n| single-record update (updateData) | yes | warnings with the hit | one line |\n| import commit (insertManyData) | yes | outcome ok: true, no warnings | one line |\n| bulk update (updateManyData) | yes | success, no warnings | one line |\n| email / sms write; an edit of a stored row not naming channel | yes | no warnings key | none |\n\n- At the engine, insert, by-id update and predicate update each hand exactly one hit to that write's own onValidationAdvisory listener. These are pinned.\n- REST's create and update routes relay the protocol answer whole (crud-routes.ts, res.status(201).json(result) and res.json(result)), so their callers receive warnings.\n- The warnings member is the existing write-answer field the spec already declares (CreateDataResponseSchema / UpdateDataResponseSchema / CloneDataResponseSchema). No response field or other surface is added.\n- Bulk and import commits report the hit only in the log. Whether the console displays a create or update answer's warnings was not measured.\n- The log line reads: Validation rule 'channel_renders_template' (warning): This channel never uses a notification template: ….\n\n## Why a format rule and not a select\n\nOption membership on a select is a field constraint in objectql's record validator. It refuses, and has no advisory severity, so it would be the 19.0 refusal and cannot be the 18.x warning.\n\nFor whoever builds 19.0, measured in round 1 at this branch's base, the select spelling:\n- refuses with the generic "Channel must be one of: email, sms", with no per-field sentence to carry the email-template pointer;\n- admits inbox on an insert carrying an import's keptOptionValues;\n- moves the column from varchar(64) to varchar(255).\n\nAt the console pin 616df6598, objectui's SelectField clears an out-of-set stored value on load. A Setup edit of a stored inbox row would therefore save channel: null, which is refused as required. That is a source reading, not a browser run.\n\n## A correction to the copy the dispatch asked for\n\nThe dispatch asked the copy to say that a notify node naming a template uses that email template on every channel. That is false for SMS at this commit. sms-channel.ts has no template path: it never reads payload.template, and it loads the (topic, 'sms', locale) row whether or not the notification names an email template. On the template path, notify-node.ts sets no title or body, so with no row the SMS body is the topic. The shipped copy says what is true: email and the inbox render the named email template, and SMS still uses its row.\n\n## Tests\n\n- New test file.packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts has 15 cases on a real ObjectQL with SqlDriver over better-sqlite3.\n - Warnings: an insert naming inbox, push, webhook, Email or ' sms', an insert carrying keptOptionValues, a by-id update, a predicate update, and a write re-naming a stored row's channel. Each is accepted, the row is stored as written, and exactly one advisory names channel_renders_template on channel (invalid_format, severity: 'warning'), with the message head and email_template.\n - Silence: email and sms inserts, and an email-to-sms update, produce no advisory.\n - Stored-row preservation: editing other columns gives no advisory, delete works, and the row renders on neither channel.\n- Ablation, rule inert. The pattern became [\\s\\S]* through scripts/ablation-replace.mjs. The 9 warning cases went red ("expected [] to have a length of 1 but got +0") and the other 6 stayed green. Restored blob == HEAD 84c52411f6b9; git diff HEAD is empty.\n- Inverse ablation, rule warns on everything. The pattern became ^(?!)$. The 3 silence cases went red ("expected [ { …(5) } ] to deeply equal []") and the other 12 stayed green. Restored blob == HEAD 84c52411f6b9.\n- Package checks at c7102b32f.pnpm --filter @objectstack/service-messaging run typecheck exits 0. The suite passes: 54 files, 608 tests.\n- Import side.@objectstack/plugin-auth's phone-sms-template-organization.integration.test.ts and phone-sms-seed-retired.test.ts pass: 7 tests.\n- Changeset level.check-changeset-no-major --base origin/main --event with this body's declaration reads "LEVEL AXIS: this PR declares clause-② no", with no direction arm, and passes at patch. check-adr-0087-registration reads "this PR adds no declared-breaking changeset".\n- Gates.dispatch-gates --commands derives 98 commands at c7102b32f; each was run with its exit code captured, and the result is in the os-dev-report on the card.\n\n## Acceptance notes\n\n- SMS ignores a notify template (see the correction above). Not filed: no measured producer sends a templated notify on sms. HotCRM's 19 templated notify nodes use inbox and email, and the examples name no template, so there is no reach reading. Carrier: the domain:services seat (the #24770 family). Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.\n- Bulk updates and import commits answer the warning only in the server log, not in their answer (measured above). This PR adds no surface to carry it. Noted, not filed. Carrier: none.\n- origin/main is ahead of this branch's last merge (5d27d326c) and does not conflict (a local merge-tree is clean), so it was not merged again.\n\n---\n_Generated by Claude Code_\n"
}
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: domain:services seat 2, the #24770 family · noted, not filed (carried from round 1) · The SMS channel ignores a notify template (sms-channel.ts has no payload.template branch; with no row the SMS body is the topic). No reach reading: no measured producer sends a templated notify on sms. Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.",
"carrier: none · noted, not filed, Acceptance notes only · Advisory validation-rule hits are answered to the caller only on single-record create, clone and update. insertManyData (import commit) and updateManyData (bulk) answer none, so their callers see the warning only in the server log. Measured on this branch by scratch test. It is an observation about an existing surface's coverage, not a defect of a declared contract: the warnings description names single-record answers.",
"carrier: none · noted, not filed (carried from round 1) · objectui SelectField at pin 616df6598 clears an out-of-set stored value on load. Source reading only. It matters to the 19.0 select spelling."
]
}
Activity
objectstack-fleet commentedon Oct 20, 2026
pm:queue→pm:blocked·domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T15:12ZThis card edits
sys_notification_template, and #24865 (in flight with this seat, claim6292807780) changes how that table is read. It waits for #24865 to close. The machine lineBlocked-by: #24865is in the body.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Scope addition from #24865's review ·
domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T17:15ZWhen this card corrects
content/docs/permissions/authentication.mdx's "SMS text customisation & localisation" paragraph (the stale seed sentence), it also states what PR #24930 (#24865) made true:sys_notification_templaterow in the organization the send resolves to,tenancy.defaultOrgId();single, that is the Default Organization's row;isolatedandgroup, no row is read and the built-in text is sent.The paragraph today calls the bodies "tenant-customisable", which is no longer true under a walled posture. Recorded in the seat's REWORK
6296720561on PR #24930.Blocked-by: #24865stands.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
pm:blocked→pm:queue·domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T18:27ZThe only blocker, #24865, closed when PR #24930 landed as
865d2c8cf4(template reads are now keyed by organization). The seat re-derived the blockers: no otherBlocked-by:applies. The scope addition in this thread (theauthentication.mdxSMS paragraph) stands.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 3 · 2026-10-20T18:55Z
Session:
session_01UmqzD97e14iws6hmVhgZ5yAccount:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24924-template-channel-email-smsWorktree:
objectstack-issue-24924Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/services/service-messaging/src/objects/notification-template.object.ts(thechanneloptions, the description and the empty-state copy) and the write path that refuses another channel, insideservice-messaging;content/docs/permissions/authentication.mdx, the "SMS text customisation & localisation" paragraph only (domain:devx; the stale seed sentence, and what PR fix(service-messaging): resolve a notification template in the recipient's organization #24930 made true: the auth SMS row is read intenancy.defaultOrgId(), the Default Organization undersingle, and underisolated/groupthe built-in text is sent);.changeset/24924-*.md.⛔ Not
packages/spec/**: thescript.config.templatetombstone half moved to #24965 (spec lane). ⛔ Nottemplate-renderer.tsor the channels (PR #24930 landed them). Stop on breach and explain in the report.Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface, derived at2941a1929: no path-derived mandate, floor sonnet, default opus, ceiling fable)Clause-②: no (expected: the
channelaccept set narrows). The narrowing owes a contract-review-tier review before the queue.Responsibility:
sys_notification_template accepts channels no reader renders, so a row saved with one renders nowhere | the email and sms channels read it; notify's own template key names an email template | every Setup admin authoring templates (card, ruling D)Thread-read: 6298141670
Serial constraints cleared: #24865 (
Blocked-by) closed with PR #24930 as865d2c8cf4. No open PR touchesservice-messaging/src/objectsorauthentication.mdx(open PRs read this round).Under the maintainer's ruling D on #24770 (
6293376907) and the standing rule 甲 (#60156292005854): the card states whether a conversion carries the narrowing. If no stored-row conversion carries the refusal, 18.x warns on write and corrects the copy, and the refusal is filed for 19.0.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24924,
"status": "done",
"branch": "claude/issue-24924-template-channel-email-sms",
"pr": "#24976",
"session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent, shared with the dispatching PM)",
"premise_still_valid": true,
"summary": "sys_notification_template.channel now accepts only email and sms. The object declares a format validation rule, channel_renders_template, whose pattern comes from one TEMPLATE_CHANNELS constant. A save naming another channel answers VALIDATION_FAILED, served 400, attributed to channel, and the message points in-app content to an email template named in a notify step's template. The rule guards insert, by-id update, predicate update and an import's keptOptionValues; stored rows with another channel are untouched except by a save that names that channel. The object description, empty state and channel/subject help say which channels use a row; they are regenerated into the four bundles and translated in zh-CN, ja-JP and es-ES. The authentication.mdx SMS paragraph drops the retired seed and states the defaultOrgId reading PR #24930 made true. Release reading under rule 甲: no ADR-0087 conversion carries data rows, and the measured shape is the dispatch's first arm (new writes refused loudly, stored rows untouched), so this ships in 18.x and no 19.0 refusal card is owed. The changeset is minor, because the changeset gate requires minor for no (narrowing).",
"tests": "All at head 6c9eed4 unless stated otherwise. (1) New pin src/objects/notification-template-channel.integration.test.ts, 13 cases on a real ObjectQL with SqlDriver over better-sqlite3. Refusals: insert naming inbox, push, webhook, Email and ' sms'; insert with keptOptionValues; by-id update; predicate update; re-save of a stored row's own channel. Each asserts resolveThrownHttpError(err).code VALIDATION_FAILED and .status 400, fields[0] {field: channel, code: invalid_format}, the message head 'Channel must be email or sms', and that it contains email_template. Positive twins: email and sms accepted. Stored-row preservation: edit of other columns, delete, and NotificationTemplateStore.load returning null for both email and sms. (2) Ablation through scripts/ablation-replace.mjs, anchor regex line x1 to x0, replacement '[\s\S]*' x0 to x1, blob 629a45203037 to 04d520ea64d9. Result 'Tests 9 failed | 4 passed (13)', every refusal red with 'AssertionError: the write was accepted: expected undefined to be defined', the 4 preservation cases green. Restore: blob 629a45203037 == HEAD, git diff HEAD empty. The object is imported from source, so no rebuild was needed for the mutation to reach the test. (3) pnpm --filter @objectstack/service-messaging run typecheck: VERDICT command-exit 0; tsc --listFiles contains the new test once. (4) pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2: 'Test Files 54 passed (54) / Tests 606 passed (606)', VERDICT command-exit 0. (5) Import side, because the exported object narrowed: plugin-auth phone-sms-template-organization.integration.test.ts and phone-sms-seed-retired.test.ts gave 'Test Files 2 passed / Tests 7 passed', after a turbo build of the plugin-auth closure (27 of 28 cached). (6) Scratch measurement of the select alternative, deleted and never committed. Insert inbox was refused with the generic 'Channel must be one of: email, sms' (invalid_option). Insert inbox with keptOptionValues was ACCEPTED. channel: null on a stored inbox row was refused 'Channel is required and cannot be cleared'. The select column is varchar(255) where the text column is varchar(64).",
"gates": "Local subset: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, derives 98 commands at 6c9eed4 (10 paths against merge base 5d27d32). Each ran with its exit code captured before any pipe; results are in scratchpad issue-24924/gates-results.tsv. 96 exited 0 on the first run. pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: no dist for client-react and 9 others). After a turbo workspace build (76 tasks, 75 cached) both exit 0. Reconciliation: dispatch-gates --ran with each line recorded as command :: exit code reads 'Run reconciliation — 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN' and '0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)'. Named among the 98, all exit 0: check-adr-0087-registration --base origin/main ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', not-required (no-migration-prescription)), check-changeset-no-major --base origin/main, check:i18n, check:i18n-stale-fill ('0 stale-fill leaf/leaves'), check-keyed-text-bounds, check:nul-bytes, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias and check:type-check-coverage. Extra: check-changeset-no-major --event with a simulated PR body passes the level axis ('direction arm: narrowing — a BREAKING change; during the launch window it ships minor'). Not run locally: check-platform-checklist --changed-from (no exported symbol renamed or deleted), test:repo (owned by CI), check:dev-prereqs and check:skill-frame-freshness (neither the packages/spec build nor the four-axis framework files are touched). CI on PR #24976 head 6c9eed4 at report time: 25 check-runs, 22 in_progress, 2 skipped, 1 success. Governed Surface Queue Guard and Check Changeset were in_progress. NOT MEASURED: CI conclusions, because the report was not deferred for CI.",
"mcp_calls": "0",
"api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs through POST /repos/objectstack-ai/objectstack/dispatches). (1) pr_create, run 39488211748: POST /repos/objectstack-ai/objectstack/pulls (draft #24976; the read-back says 7572 bytes sent and 7572 stored, identical) plus POST /repos//issues/24976/assignees (os-support-ai). (2) This os-dev-report comment, posted with scripts/pm/post-stamped.mjs: POST /repos//issues/24924/comments. Separately, git push of the branch, which is not a REST write. No labels were written: the dispatch named none and a changeset exists. Reads otherwise: REST GET on issues/24924 and its comments, comments 6290284887, 6290342354, 6293376907 and 6292005854, pulls/24976, and commits/6c9eed45b/check-runs.",
"line_budget": "+331 / -33 across 10 files against merge base 5d27d32. Hand-written: notification-template.object.ts +84/-6; the new pin test +177; authentication.mdx +16/-5; changeset +18. Generated or translated bundles: en, zh-CN, ja-JP and es-ES objects +9/-4 each; es-ES and ja-JP source-hashes -3 each. The PR stays under the 3,000-line queue threshold, and no governed surface is touched (Tier: none).",
"files_changed": [
".changeset/24924-template-channel-email-sms.md",
"content/docs/permissions/authentication.mdx",
"packages/services/service-messaging/src/objects/notification-template.object.ts",
"packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts",
"packages/services/service-messaging/src/translations/en.objects.generated.ts",
"packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts"
],
"deviations": [
"Route, from the suggested-route section. The write path is the object's own format validation rule, not select options plus the platform's select validation. I measured the select spelling on this branch, and it fails three ways. Its refusal can only be the generic catalog sentence ('Channel must be one of: email, sms'): the catalog is keyed by message key, with no per-field override, so it cannot carry the email-template pointer the card requires. An import's keptOptionValues makes it ACCEPT inbox. And, by source reading at the console pin 616df6598, objectui's SelectField clears an out-of-set stored value on load (packages/fields/src/widgets/SelectField.tsx cascade clear; packages/core/src/evaluator/optionRules.ts isValueStillOffered), so a Setup edit of any column of a stored inbox row would send channel: null. That write is measured refused as required: a stored-row path newly refused, which would push the refusal to 19.0 under rule 甲. The format rule refuses on every engine write path, carries the prescription, and leaves stored rows and the varchar(64) column untouched. The field stays Field.text. The objectui half is a source reading, not a browser run.",
"The changeset is minor, not the patch the dispatch expected. check-changeset-no-major grades a declared 'Clause-②: no (narrowing)' as breaking, and minor is the launch-window grade (measured). The changeset carries the ADR-0087 marker not-required (no-migration-prescription) and BREAKING FROM/TO bullets, in the shape of the precedent changesets 24813, 24765 and 24707.",
"Copy. The dispatch and card asked the copy to say that a notify node naming a template uses that email template on every channel. That is measured false for SMS: sms-channel.ts never reads payload.template and always loads the (topic, sms, locale) row, and with no row the SMS body is the topic, because notify-node.ts sets no title or body on the template path. The shipped copy says that email and the inbox render the named email template and that SMS still uses its row here. The PR body has a section on this.",
"Scope, within the declared file: the subject field's help also changed. It dropped 'inbox title' and now says what SMS does with the subject. Same object copy, same defect (it promised the inbox); ruling D's option text named it.",
"origin/main advanced 3 commits (5f11cec, 693352e, 6440cf1) after the branch merged 5d27d32 and before pr_create. Those commits touch cli, plugin-security and docs/qa, disjoint from this diff. I did not merge again, to avoid re-running the 98-gate union for a disjoint change; the PR body states this.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By. The repo rule and its pre-push hook take precedence.",
"One scratch measurement test was created inside the worktree for the select reading, then deleted before any commit; git status was clean afterwards."
],
"pr_body_edits": [],
"open_questions": [],
"out_of_scope_findings": [
"carrier: domain:services seat 2, the #24770 family · noted, not filed · The SMS channel ignores a notify template. sms-channel.ts has no payload.template branch, so a notify naming an email template with channels including sms sends the (topic, sms, locale) row if one exists, otherwise the topic string, because notify-node.ts sets no title or body on the template path. NotifyConfigSchema.template's describe ('renders subject/body from that member') does not say it is email and inbox only. No reach reading: no measured producer sends a templated notify on sms (HotCRM's 19 templated notify nodes are inbox and email; the examples name no template). Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.",
"carrier: none · noted, not filed, Acceptance notes only · objectui SelectField at pin 616df6598 clears a stored value its options do not offer when the form loads. On a non-cascading select, an edit form would then save null for a value the user never touched. Source reading only, with no browser measurement and no reach reading. It blocks a future select spelling of this channel field."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Seat correction, and REWORK of PR #24976 (patch round 1) ·
domain:servicesseat 2 (seat post #21118) ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T20:09ZThe seat's error. The dispatch put the seat's own reading into its binding release clause: "If the object's options and write path refuse a new non-email/sms row loudly, and stored rows are untouched, the narrowing of NEW writes is a fix and ships in 18.x." No ruling carries that.
What governs. The ruling record (
6293376907) 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." This card's own Release section says the same.6292005854), 18.x ships the warning half and the corrected copy, and the refusal waits for 19.0.REWORK, same claim. The same dev continues; this is not a fresh dispatch.
TEMPLATE_CHANNELS, the corrected object copy and theauthentication.mdxparagraph stay.Clause-②:line are re-derived from what then ships.The contract review the seat had started on head
6c9eed45bwas stopped unposted, because that head is superseded.objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24924,
"status": "done",
"branch": "claude/issue-24924-template-channel-email-sms",
"pr": "#24976",
"session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent, shared with the dispatching PM)",
"round": "REWORK patch round 1 (card comment 6300407976); head c7102b3",
"premise_still_valid": true,
"summary": "18.x warning half of ruling D. channel_renders_template keeps its pattern from TEMPLATE_CHANNELS and now carries severity 'warning', with a warning's wording. A write naming a channel other than email or sms is accepted and stored, and it reports exactly one advisory: rule channel_renders_template, severity warning, field channel, code invalid_format, plus the message. The advisory reaches the write's onValidationAdvisory listener and is logged once per write. Single-record create and update answers carry it in their existing warnings member; bulk-update and import-commit answers do not (it reaches the log only). No surface was added. Nothing is refused, and no pin asserts a refusal. TEMPLATE_CHANNELS, the corrected copy, the authentication.mdx paragraph and the SMS copy correction are kept. The changeset is patch with 'Clause-②: no' plus a one-line reason, which check-changeset-no-major measures clean.",
"tests": "All at head c7102b3. (1) Pin src/objects/notification-template-channel.integration.test.ts, 15 cases on a real ObjectQL with SqlDriver over better-sqlite3. Nine cases each check that the write is accepted, the row is stored as written, and the write's own listener gets exactly one advisory {rule: channel_renders_template, severity: warning, field: channel, code: invalid_format} whose message starts 'This channel never uses a notification template' and contains email_template. Those nine are: an insert naming inbox, push, webhook, Email or ' sms'; an insert carrying keptOptionValues; a by-id update; a predicate update; a re-save of a stored row's own channel. Silence: email and sms inserts, and an email-to-sms update, produce an empty advisory list. Stored-row preservation: an edit of other columns produces no advisory, the row can be deleted, and NotificationTemplateStore.load is null for both email and sms. No refusal assertion remains: a grep of the test for refus, toThrow and rejects hits only the docblock prose. (2) Ablation, rule inert (pattern to '[\s\S]*' via scripts/ablation-replace.mjs; anchor x1 to x0; blob 84c52411f6b9 to ffb658f4350e): 'Tests 9 failed | 6 passed (15)', each red case 'AssertionError: expected [] to have a length of 1 but got +0'; restored blob 84c52411f6b9 == HEAD, git diff HEAD empty. (3) Inverse ablation, rule warns on everything (pattern '^(?!)$'; blob to e218b3a5764d): 'Tests 3 failed | 12 passed (15)', the three silence cases red with 'expected [ { …(5) } ] to deeply equal []'; restored blob == HEAD 84c52411f6b9. (4) pnpm --filter @objectstack/service-messaging run typecheck and the full suite under the lock: VERDICT command-exit 0, 'Test Files 54 passed (54) / Tests 608 passed (608)'. (5) The object moved, so plugin-auth's phone-sms-template-organization.integration.test.ts and phone-sms-seed-retired.test.ts were re-run after a turbo build of the plugin-auth closure (27 of 28 cached): 'Test Files 2 passed / Tests 7 passed'. (6) Scratch measurement through the real ObjectStackProtocolImplementation (absolute dist import; deleted, never committed). createData inbox answered warnings [{rule: channel_renders_template, severity: warning, field: channel, code: invalid_format, message}] and stored channel inbox. updateData email to push answered the same warnings. createData email answered no warnings key, and updateData of the body only on the push row answered no warnings key. insertManyData webhook (the import commit path) answered outcomes [{ok: true}] with no warnings. updateManyData to chat answered success with no warnings. Stored channels were chat, inbox, webhook; there was one log line per non-email/sms write (4), each 'Validation rule 'channel_renders_template' (warning): This channel never uses…'. REST crud-routes.ts relays the createData/updateData answers whole (source reading). The console's display of warnings was not measured.",
"gates": "Local subset at c7102b3. dispatch-gates --commands --repo objectstack-ai/objectstack, no paths, derives 98 commands, the same set as round 1. All 98 exit 0, each exit captured before any pipe (scratchpad issue-24924/gates-results2.tsv). This includes check-adr-0087-registration --base origin/main ('this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'), check-changeset-no-major --base origin/main, check:i18n, check:i18n-stale-fill, check-keyed-text-bounds, check:nul-bytes, check:skill-examples and check:dual-build-cjs-loads; the last two passed after a turbo workspace build, 76 tasks with 52 cached. The --ran reconciliation reads 'Run reconciliation — 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN' and '0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)'. Changeset level with the new declaration, as the coordinator asked: check-changeset-no-major --base origin/main --event, with a simulated body whose Clause-② line is the pr_body_edits one, exits 0. It reads '✓ LEVEL AXIS: this PR declares clause-②
no, so no package here is declared to have grown a published surface' and 'direction arm: none declared', with '@objectstack/service-messaging': patch. Not merged: origin/main 6440cf1 does not conflict (git merge-tree --write-tree exit 0). PR #24976 check-runs at c7102b3: 42 runs, 35 success, 6 skipped, 1 failure. Required: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core and Governed Surface Queue Guard are all success. Check Changeset is failure: it reads the stale PR body line 'Clause-②: no (narrowing)', which pr_body_edits replaces; the changeset was not raised to minor.","mcp_calls": "0",
"api_writes": "1 this round: the os-dev-report comment on #24924 through scripts/pm/post-stamped.mjs (fleet relay; POST /repos//issues/24924/comments). Also git push of c7102b3 to claude/issue-24924-template-channel-email-sms, which is not a REST write. No PR body edit: the replacement is in pr_body_edits. No labels. Reads: REST GET on comments 6300407976 and issues/24924/comments, and commits/c7102b32f/check-runs.",
"line_budget": "Branch: +350 / -33 across 10 files against merge base 5d27d32. This round: +133 / -114 across 7 files against 6c9eed4. Per file against the merge base: notification-template.object.ts +88/-6; pin test +196; authentication.mdx +16/-5; changeset +14; en/zh-CN/ja-JP/es-ES objects bundles +9/-4 each; es-ES and ja-JP source-hashes -3 each. Under the 3,000-line queue threshold; no governed surface (Tier: none).",
"files_changed": [
".changeset/24924-template-channel-email-sms.md",
"content/docs/permissions/authentication.mdx",
"packages/services/service-messaging/src/objects/notification-template.object.ts",
"packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts",
"packages/services/service-messaging/src/translations/en.objects.generated.ts",
"packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.objects.generated.ts",
"packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts",
"packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts"
],
"deviations": [
"The PM's mechanism assumption holds. A non-error rule is an advisory, the write goes through, there is one per-write log line, and the hit goes to the write's onValidationAdvisory listener. One nuance was measured: only single-record createData, cloneData and updateData answer it to the caller (existing warnings member, relayed whole by REST create/update). insertManyData (import commit) and updateManyData (bulk) answer no warnings, so there the warning is the log line only. Reported plainly; no surface added.",
"The format rule stays text-plus-rule rather than a select. The reason is now that select option membership is a refusing field constraint with no advisory severity, so a select would be the 19.0 refusal. Round 1's select measurements are kept in the object comment and the PR body for whoever builds 19.0.",
"An inverse ablation (pattern '^(?!)$', the rule warns on everything) was added beside the requested one, so the silence pins are also shown able to fail.",
"The pin test grows from 13 to 15 cases. It adds an email-to-sms update with no advisory and splits the positive twins per channel.",
"The worktree /home/user/objectstack-issue-24924 was recreated with git worktree add on the existing local branch claude/issue-24924-template-channel-email-sms (head 6c9eed4 == origin). After this round's report it is removed again (node_modules first, then git worktree remove without --force).",
"Commit trailers use AGENTS.md's model-free pair, as in round 1."
],
"pr_body_edits": [
{
"pull": 24976,
"edit": "replace the whole body with the text below; line 1 stays 'Fixes #24924', line 3 is the new Clause-② declaration at line start",
"body": "Fixes #24924\n\nClause-②: 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.\n\n## What changes\n\nThis is the 18.x half of execution card ② of the maintainer's ruling D on #24770.sys_notification_template.channelnames a channel that reads a template row (emailorsms), or the write is warned about. Patch round 1 replaced the refusal this PR first shipped: the seat's correction is on the card, comment 6300407976.\n\n- Write warning. The object declares oneformatvalidation rule,channel_renders_template, atseverity: 'warning'. Its pattern is built from a singleTEMPLATE_CHANNELSconstant (email,sms). A write naming any other channel is accepted and stored, and it reports one advisory. The advisory carriesrule: 'channel_renders_template',severity: 'warning',field: 'channel',code: 'invalid_format', and this 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."\n- Copy. The object description, theallview's empty state, and thechannelandsubjecthelp now say which channels use a row and that the inbox never does. They are translated in zh-CN, ja-JP and es-ES, as is the warning. The es-ES and ja-JP leaves for the changed keys were untranslated English fills; they are translations now, so their provenance hashes drop.\n- Docs. Incontent/docs/permissions/authentication.mdx, the "SMS text customisation & localisation" paragraph loses the retired-seed sentence. It now states what PR #24930 made true: the auth SMS row is read intenancy.defaultOrgId(). Undersinglethat is the Default Organization's row. Underisolatedandgroupno row is read and the built-in text is sent.\n- Not touched:packages/spec/**(thescript.config.templatetombstone half belongs to #24965 in the spec lane),template-renderer.ts, and the channels.\n\n## Release reading (standing rule 甲)\n\nThe ruling record (6293376907) 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."\n\n- No ADR-0087 conversion carries this narrowing, because the rows are stored data of a platform object, not authored metadata.\n- So 18.x ships the warning half and the corrected copy.\n- The refusal of stored and new rows is filed for 19.0, by the seat.\n- Nothing in this PR refuses a write, and no pin asserts a refusal.\n\n## Where the warning reaches (measured)\n\nMeasured on a real ObjectQL with SqlDriver at this branch; the protocol half is a scratch test that is not committed.\n\n| Write | Accepted and stored | What the caller is answered | Server log |\n|---|---|---|---|\n| single-record create (createData) | yes |warningswith the hit | one line |\n| single-record update (updateData) | yes |warningswith the hit | one line |\n| import commit (insertManyData) | yes | outcomeok: true, nowarnings| one line |\n| bulk update (updateManyData) | yes |success, nowarnings| one line |\n|email/smswrite; an edit of a stored row not namingchannel| yes | nowarningskey | none |\n\n- At the engine, insert, by-id update and predicate update each hand exactly one hit to that write's ownonValidationAdvisorylistener. These are pinned.\n- REST's create and update routes relay the protocol answer whole (crud-routes.ts,res.status(201).json(result)andres.json(result)), so their callers receivewarnings.\n- Thewarningsmember is the existing write-answer field the spec already declares (CreateDataResponseSchema/UpdateDataResponseSchema/CloneDataResponseSchema). No response field or other surface is added.\n- Bulk and import commits report the hit only in the log. Whether the console displays a create or update answer'swarningswas not measured.\n- The log line reads:Validation rule 'channel_renders_template' (warning): This channel never uses a notification template: ….\n\n## Why aformatrule and not aselect\n\nOption membership on aselectis a field constraint in objectql's record validator. It refuses, and has no advisory severity, so it would be the 19.0 refusal and cannot be the 18.x warning.\n\nFor whoever builds 19.0, measured in round 1 at this branch's base, theselectspelling:\n- refuses with the generic "Channel must be one of: email, sms", with no per-field sentence to carry the email-template pointer;\n- admitsinboxon an insert carrying an import'skeptOptionValues;\n- moves the column fromvarchar(64)tovarchar(255).\n\nAt the console pin616df6598, objectui'sSelectFieldclears an out-of-set stored value on load. A Setup edit of a storedinboxrow would therefore savechannel: null, which is refused asrequired. That is a source reading, not a browser run.\n\n## A correction to the copy the dispatch asked for\n\nThe dispatch asked the copy to say that a notify node naming atemplateuses that email template on every channel. That is false for SMS at this commit.sms-channel.tshas no template path: it never readspayload.template, and it loads the(topic, 'sms', locale)row whether or not the notification names an email template. On the template path,notify-node.tssets no title or body, so with no row the SMS body is the topic. The shipped copy says what is true: email and the inbox render the named email template, and SMS still uses its row.\n\n## Tests\n\n- New test file.packages/services/service-messaging/src/objects/notification-template-channel.integration.test.tshas 15 cases on a real ObjectQL with SqlDriver over better-sqlite3.\n - Warnings: an insert naminginbox,push,webhook,Emailor' sms', an insert carryingkeptOptionValues, a by-id update, a predicate update, and a write re-naming a stored row's channel. Each is accepted, the row is stored as written, and exactly one advisory nameschannel_renders_templateonchannel(invalid_format,severity: 'warning'), with the message head andemail_template.\n - Silence:emailandsmsinserts, and an email-to-sms update, produce no advisory.\n - Stored-row preservation: editing other columns gives no advisory, delete works, and the row renders on neither channel.\n- Ablation, rule inert. The pattern became[\\s\\S]*throughscripts/ablation-replace.mjs. The 9 warning cases went red ("expected [] to have a length of 1 but got +0") and the other 6 stayed green. Restored blob == HEAD84c52411f6b9;git diff HEADis empty.\n- Inverse ablation, rule warns on everything. The pattern became^(?!)$. The 3 silence cases went red ("expected [ { …(5) } ] to deeply equal []") and the other 12 stayed green. Restored blob == HEAD84c52411f6b9.\n- Package checks atc7102b32f.pnpm --filter @objectstack/service-messaging run typecheckexits 0. The suite passes: 54 files, 608 tests.\n- Import side.@objectstack/plugin-auth'sphone-sms-template-organization.integration.test.tsandphone-sms-seed-retired.test.tspass: 7 tests.\n- Changeset level.check-changeset-no-major --base origin/main --eventwith this body's declaration reads "LEVEL AXIS: this PR declares clause-②no", with no direction arm, and passes atpatch.check-adr-0087-registrationreads "this PR adds no declared-breaking changeset".\n- Gates.dispatch-gates --commandsderives 98 commands atc7102b32f; each was run with its exit code captured, and the result is in the os-dev-report on the card.\n\n## Acceptance notes\n\n- SMS ignores a notifytemplate(see the correction above). Not filed: no measured producer sends a templated notify onsms. HotCRM's 19 templated notify nodes use inbox and email, and the examples name no template, so there is no reach reading. Carrier: thedomain:servicesseat (the #24770 family). Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.\n- Bulk updates and import commits answer the warning only in the server log, not in their answer (measured above). This PR adds no surface to carry it. Noted, not filed. Carrier: none.\n-origin/mainis ahead of this branch's last merge (5d27d326c) and does not conflict (a local merge-tree is clean), so it was not merged again.\n\n---\n_Generated by Claude Code_\n"}
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: domain:services seat 2, the #24770 family · noted, not filed (carried from round 1) · The SMS channel ignores a notify template (sms-channel.ts has no payload.template branch; with no row the SMS body is the topic). No reach reading: no measured producer sends a templated notify on sms. Dedupe words: sms channel notify template ignored; payload.template sms; templated notify sms topic body.",
"carrier: none · noted, not filed, Acceptance notes only · Advisory validation-rule hits are answered to the caller only on single-record create, clone and update. insertManyData (import commit) and updateManyData (bulk) answer none, so their callers see the warning only in the server log. Measured on this branch by scratch test. It is an observation about an existing surface's coverage, not a defect of a declared contract: the warnings description names single-record answers.",
"carrier: none · noted, not filed (carried from round 1) · objectui SelectField at pin 616df6598 clears an out-of-set stored value on load. Source reading only. It matters to the 19.0 select spelling."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24976 comment 6301317919 ·
domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T20:57Z · headc7102b32fe, ordinary queue landing.objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T21:27Z0603bfa1cf.git merge-base --is-ancestor 0603bfa1cf origin/mainholds. The card closed byFixes.channel_renders_templatewarns on a write naming a channel other thanemail/sms, and the copy is corrected.pm:on-hold,Restart-when:the 19.0 line).6301298178), for that card's closure review beside service-messaging: retire sys_notification_subscription (no runtime reader), by the maintainer's ruling D on #24770 #24923.pm:dispatchedcomes off in this act.