Repository navigation
fix(service-messaging)!: sys_notification_template refuses a channel other than email or sms, stored rows included - #25028
Conversation
…channel other than email or sms The channel_renders_template rule becomes a cross_field invariant at severity error: a write that leaves a row naming a channel outside TEMPLATE_CHANNELS is refused, for new rows and stored rows alike. Stored rows stay readable and deletable; nothing is rewritten. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…ate channel refusal Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…hannel refusal copy Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…ackage's tests Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…mplate-channel-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e215fc18b2390b764932df3e717172585c2f71a && git checkout 9e215fc18b2390b764932df3e717172585c2f71a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d991588ffcdedce1b79fc96ba2779c67c4f8d62 6974906eba3c466411790992a90f7349febba7bb && git checkout -B drift-repro 4d991588ffcdedce1b79fc96ba2779c67c4f8d62 && git merge --no-ff 6974906eba3c466411790992a90f7349febba7bb
node scripts/docs-audit/affected-docs.mjs --json 4d991588ffcdedce1b79fc96ba2779c67c4f8d62
|
…efusal makes false The 24924 entry described a warning that the 24983 refusal supersedes in the same release; its headline, the warning paragraph, its Clause-② line and its Unchanged sentence now describe what ships. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
|
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsInputs: card #24983 (body and every comment, rulings
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Posted 2026-10-21T02:37Z · contract review at the narrowing face, PR #25028, card #24983 |
席位核对 · ACCEPT ·
|
Fixes #24983
Clause-②: no (narrowing)
What changes
The maintainer's ruling D on #24770 (record
6293376907) reads: "② refuses stored or new non-email/sms rows." The amended standing rule 「甲」 (#6015,6303865025) ships a narrowing that no conversion carries now, in 18.x, as a BREAKING minor. The 18.x warning half is already inmain(#24924, PR #24976,0603bfa1cf). This PR turns that warning into the refusal.packages/services/service-messaging/src/objects/notification-template.object.ts.channel_renders_templatewas aformatrule atseverity: 'warning'. It is now across_fieldrule atseverity: 'error', withfields: ['channel']and the condition!(record.channel in ["email","sms"]). The condition is built fromTEMPLATE_CHANNELS, the only copy of the list. The constant is now exported from the module for this package's tests; it is not on the package index. The rule's message is now the refusal with the ruling's prescription. Thechannelhelp now says that any other value is refused. The field staystext, and its column (varchar(64)) is unchanged.en.objects.generated.tswas regenerated withnode scripts/check-i18n-bundles.mjs --write --filter=service-messaging. The zh-CN, ja-JP and es-ES values of the two changed keys (the rule message and thechannelhelp) were re-translated by hand, as AGENTS.md allows for translated-locale values. The tool's check then readsservices/service-messaging in sync (7 bundle(s)). No source-hash entry moved.notification-template-channel.integration.test.tsis deleted and replaced bynotification-template-channel-refusal.integration.test.ts. The old file had 15 cases pinning the advisory this PR removes, and each warning case asserted that the write was accepted. The new file has 18 cases (below).package.json,pnpm-lock.yaml,vitest.config.ts.@objectstack/metadata-protocolbecomes a devDependency (a 3-line importer entry in the lockfile), so the pins drive the real data protocol.vitest.config.tsaliases it to source, becausecheck:test-source-aliasrequires an alias and its registry is shrink-only..changeset/24983-template-channel-refusal.md.@objectstack/service-messagingminor, in the packages: POST /packages/:id/adopt-orphans skips requireWritablePackage, so it can bind loose items into a read-only package #24813 BREAKING shape: a headline with!, theClause-②: no (narrowing)line, and the ADR-0087 disposition markernot-required (no-migration-prescription)with its category reasoning. It also carries the BREAKING banner and the FROM / TO / Unchanged / fix bullets.A contract-tier review of the narrowing is owed before the queue; the seat runs it.
Contract: the accept set, sentence by sentence
Who is refused. Every caller that writes
sys_notification_templateis refused: Setup admins, API clients, imports, bulk edits and in-process engine writes. A system-context write is refused too, because an error-severity rule is never skipped for system writes (rule-validator.ts, the "ERROR-severity rules are deliberately NOT gated" note).What is refused. A write is refused when the row it leaves behind names a
channelother than exactlyemailorsms. The check is exact and case-sensitive, soEmailandsmsare refused. A null channel is out of the set too: a stored row whose channel is null is refused on edit, and a write that nullschannelis still refused first byrequired.New rows and moves, at each door (Before =
mainat73432299c0):POST /data/sys_notification_templatenaminginbox(orpush,webhook,Email, ...)201, stored, one advisory inwarnings400,code: VALIDATION_FAILED, one finding{ field: 'channel', code: 'rule_violation', message }carrying the prescription; nothing storedPATCH /data/sys_notification_template/IDmoving anemail/smsrow to another channel200plus an advisory400as above; the row keeps its channelinboxrow (runImport, both dry run and commit)ok: true; the advisory appears only in the server logok: false, action: 'failed', field: 'channel', code: 'rule_violation', error: MESSAGE; the other rows landupdateManyData), a row that would leave its channel out of the setsuccess: false, errors: [{ code: 'VALIDATION_FAILED', httpStatus: 400, message }]; withcontinueOnError, the other rows landmulti: true) over a set holding such a rowchannelemailorsmsRows already stored with another channel. Nothing in this PR reads them to rewrite them, and no data moves.
GETbody,is_active). Setup's edit form sends only the changed fields, so this is its usual save.400with the prescription; the row is left as storedemailorsmsThis is the spec's documented "frozen, not bricked" invariant (
packages/spec/src/data/validation.zod.ts, "Transition gate vs invariant"). Such a row can be read, deleted or repaired, and nothing else.Why this spelling, from measurement
The table below was measured on a real
ObjectQLoverSqlDriver(better-sqlite3) at62bc129308, through scratch tests that are not committed. Every variant used the shipped object with only the rule or field swapped.formataterrorcross_fieldaterror(chosen)selectwith optionsemail/smsinboxinsertinvalid_format, the authored messagerule_violation, the authored messageinvalid_option, the generic "Channel must be one of: email, sms"insertMany)inboxrow: editbodyoris_activechannel: 'inbox'channelcolumnvarchar(64)varchar(64)varchar(255)How the card's four readings fare at this base:
selectgeneric sentence: re-measured; it holds.keptOptionValues: re-measured; it holds.SelectFieldclearing an out-of-set value: not re-measured. It does not apply, because the field staystext.One more source reading decides against the
formatspelling. At the current console pincf153eb2e5,dirtyEditPayload(packages/plugin-form/src/sanitize.ts) sends only the changed fields on an edit save. So underformat, an admin's Setup edit of a storedinboxrow would be accepted silently, and the ruling's "refuses stored rows" would hold at no door the console uses. That is a source reading, not a browser run. ADR-0113 D1's non-regression invariant is scoped to the field-levelrequired/requiredWhen, not to object validation rules.Decision analysis (four axes)
The three options are: A,
formataterror(it judges the write); B, across_fieldinvariant (chosen); and C, aselect.fixture-18.0,fixture-18.0.0) hold 0 rows. Readers are the email channel's inline path, the SMS channel and plugin-auth's phone texts, all keyed onemail/sms. So a row for another channel has no reader, and nothing needs it to stay editable. B gives the ruling's "refuses stored rows" with no data move. A leaves stored rows editable and silent at Setup's usual save. C is no better than A for stored rows, and it drops the prescription.is_active: false; it must be deleted or moved toemail/sms. A predicate update over a set that holds such a row is refused whole until the row is fixed. Both refusals carry the prescription.Tests (head
677e07e66e)New file:
notification-template-channel-refusal.integration.test.ts, 18 cases on a realObjectQLoverSqlDriver. It drives the realObjectStackProtocolImplementationandmapDataError(the REST data door's classifier, whichPOST/PATCH /data/:objectanswer a thrown write with), and runsrunImportfor the import commit. Every refusal asserts thecode(VALIDATION_FAILED), the status (400, or the row'shttpStatus: 400), the finding (field: 'channel',code: 'rule_violation'), the message's first sentence andemail_template, and that the row is unchanged.inbox,push,webhook,Email,sms), and theemailandsmscreates are accepted. Also covered: a kept-option-values insert is refused, a by-id move from email toinboxis refused, a by-id move between email and sms is accepted, and an import commit lands the sms row and fails the inbox row.inboxrow (written through the driver):bodyoris_activeis refused;emailis accepted, and so is a later edit;NotificationTemplateStore.loadreturnsnullfor both channels).Package runs (under the shared lock):
pnpm --filter @objectstack/service-messaging run test:Test Files 55 passed (55)·Tests 621 passed (621), at677e07e66e(after mergingmain). Before the merge: 54 files and 611 tests at5be5094652.pnpm --filter @objectstack/service-messaging run typecheck: exit 0.tsc --listFilesincludes the new test file.Ablation (three legs, one per pinned behaviour)
Each leg mutated the committed condition line through
scripts/ablation-replace.mjs. Each mutation was proved on disk: the anchor count went from 1 to 0, the replacement count from 0 to 1, and the blob changed. The leg then ran the new file under the lock. Afterwards the file was restored withgit checkout HEAD -- PATH, and the restore was proved by blob equality withHEAD(9675ee5837b5) and an emptygit diff HEAD. The object is imported by a relative path fromsrc, so nodist/is involved.falsetrueGates
Locally, at
677e07e66e, the derived set ran under the shared lock as one runner. CI runs everything else: the lint workflow's whole farm and the PR check-runs are its readings, not re-run here.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 81 commands from 11 changed paths against merge base73432299c. Each exit code was captured before any pipe.pnpm check:dual-build-cjs-loadsexited 3,PREREQUISITE NOT MET, because 8 workspace packages outside this diff's closure had nodist/. I built those 8 (pnpm turbo run buildwith eight filters, all 59 tasks cache hits) and re-ran the gate: exit 0, "112 published require entry point(s) across 69 package(s) load".node scripts/pm/dispatch-gates.mjs --ran ran.listreads "81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED (a DERIVED zero — all 81 recorded an exit code and none of them is 3)".check-adr-0087-registration --base origin/main: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", read as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription);pnpm check:i18nexit 0;pnpm check:test-source-aliasexit 0. It was red once before the alias was added, naming@objectstack/metadata-protocol;pnpm check:workspace-manifest-cyclesexit 0;pnpm check:nul-bytesexit 0.test:repo(the vitestrepoproject) is CI's: this diff edits none of its tests and the derivation does not name it.pnpm lintis CI's too.Dogfood battery
The full battery ran once, on the final head
677e07e66e, as CI runs it:bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/dogfood test', with noOS_TEST_SHARDand no path. Its closure was built first withpnpm turbo run build --filter=@objectstack/dogfood --concurrency=2(66 tasks).os-verify-lock: VERDICT command-exit 0 · held the lock 741s (12m21s), a reading on a shared box.Test Files 284 passed | 3 skipped (287)·Tests 2361 passed | 18 skipped (2379). No failure.fixture-18.0,fixture-18.0.0) hold 0sys_notification_templaterows and keepchannel varchar(64), so no fixture is stale under this change.Acceptance notes
.changeset/24924-template-channel-email-sms.md(patch, still pending in.changeset/) says that a write naming another channel "is still accepted and stored". If it ships in the same release as this PR, the compiled CHANGELOG would carry that sentence beside this refusal. Release notes are compiled centrally at release time, so this PR does not edit another card's changeset. It is reported to the seat in the dev report.sys_notification_templaterow, "MJML for email, JSON for others") describes templates for other channels that never got a reader. Ruling D narrows the object, and the ruling asked for no ADR edit on this card (it asked for one only on card ①). Noted, not filed. Carrier: none.400finding onchannelwhen an admin editsbodywas not measured in a browser. The finding nameschannel, so the field the admin must fix is the one marked.Seat's append (
domain:servicesseat 1, from the dev's patch-round report6306289251on #24983; the dev writes its PR body once):Corrected pending note
This PR also corrects
.changeset/24924-template-channel-email-sms.md, the pending note from PR #24976 (#24924). Once this PR lands, both entries ship in one release, and parts of that note would no longer be true. Only the false sentences are rewritten (commit6974906eba). These are kept byte for byte:'@objectstack/service-messaging': patch);channel/subjecthelp now say which channels use a row and that the inbox never does.");Check Changeset(check-empty-changeset) is red on purpose, as DELIBERATE CORRECTION; the file has not been restored. The seat named three sentences: the second sentence of the warning paragraph, theClause-②line and the first sentence of Unchanged. The headline and the paragraph's other four sentences were false the same way, so they are rewritten too and quoted here.1. The headline.
sys_notification_templatesaved for a channel other thanemailorsmsis now warned about, because no channel ever sends it"sys_notification_templatecopy now says that only the email and SMS channels use a template row, and that the in-app inbox never does"2. The warning paragraph. Five sentences become one; the paragraph's closing copy sentence is kept.
channel_renders_templaterule atseverity: 'warning'. A write naming another channel is still accepted and stored, and it now reports the warning. A single-record create, clone or update answers it in the write answer'swarnings(rule,severity,field: 'channel',code,message), and the server logs it once per write. Bulk updates and imports log it. The message points in-app content to an email template (email_template) named in a notify step'stemplate."channel_renders_templatewarning for such a write; in the same release service-messaging: refuse sys_notification_template rows for a channel other than email or sms (the 19.0 half of ruling D) #24983's refusal superseded it, so a write naming another channel is refused rather than warned about, as that entry describes."3. The
Clause-②line.warnings."4. The first sentence of Unchanged.
Code at the head is byte-identical to
677e07e66e, where the tests, the gates and the dogfood battery ran:git diff --stat 677e07e66e 6974906ebaprints.changeset/24924-template-channel-email-sms.md | 8 ++++----and1 file changed, 4 insertions(+), 4 deletions(-). At the new head,node scripts/check-empty-changeset.mjs --base origin/mainexits 1 with the DELIBERATE CORRECTION class line, andcheck-changeset-no-major,check-adr-0087-registration,check-changeset-fixedandcheck:nul-bytesexit 0.Generated by Claude Code