Skip to content

fix(service-messaging)!: sys_notification_template refuses a channel other than email or sms, stored rows included - #25028

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24983-template-channel-refusal
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24983-template-channel-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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 in main (#24924, PR #24976, 0603bfa1cf). This PR turns that warning into the refusal.

  • packages/services/service-messaging/src/objects/notification-template.object.ts. channel_renders_template was a format rule at severity: 'warning'. It is now a cross_field rule at severity: 'error', with fields: ['channel'] and the condition !(record.channel in ["email","sms"]). The condition is built from TEMPLATE_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. The channel help now says that any other value is refused. The field stays text, and its column (varchar(64)) is unchanged.
  • Translations. en.objects.generated.ts was regenerated with node 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 the channel help) were re-translated by hand, as AGENTS.md allows for translated-locale values. The tool's check then reads services/service-messaging in sync (7 bundle(s)). No source-hash entry moved.
  • Tests. notification-template-channel.integration.test.ts is deleted and replaced by notification-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-protocol becomes a devDependency (a 3-line importer entry in the lockfile), so the pins drive the real data protocol. vitest.config.ts aliases it to source, because check:test-source-alias requires an alias and its registry is shrink-only.
  • .changeset/24983-template-channel-refusal.md. @objectstack/service-messaging minor, 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 !, the Clause-②: no (narrowing) line, and the ADR-0087 disposition marker not-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_template is 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 channel other than exactly email or sms. The check is exact and case-sensitive, so Email and sms are 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 nulls channel is still refused first by required.

New rows and moves, at each door (Before = main at 73432299c0):

Write Before After
POST /data/sys_notification_template naming inbox (or push, webhook, Email, ...) 201, stored, one advisory in warnings 400, code: VALIDATION_FAILED, one finding { field: 'channel', code: 'rule_violation', message } carrying the prescription; nothing stored
PATCH /data/sys_notification_template/ID moving an email / sms row to another channel 200 plus an advisory 400 as above; the row keeps its channel
Import commit with an inbox row (runImport, both dry run and commit) the row reports ok: true; the advisory appears only in the server log the row reports ok: false, action: 'failed', field: 'channel', code: 'rule_violation', error: MESSAGE; the other rows land
Bulk update (updateManyData), a row that would leave its channel out of the set the row succeeds the row reports success: false, errors: [{ code: 'VALIDATION_FAILED', httpStatus: 400, message }]; with continueOnError, the other rows land
Engine predicate update (multi: true) over a set holding such a row every matched row is written the whole update is refused and no row is written
An insert carrying an import's kept option values for channel stored, plus an advisory refused
A write that leaves email or sms accepted accepted (unchanged)

Rows already stored with another channel. Nothing in this PR reads them to rewrite them, and no data moves.

Shape Before After
Read, list or GET served served (unchanged)
Delete deleted deleted (unchanged; a delete runs no validation rule)
An edit of another column (body, is_active). Setup's edit form sends only the changed fields, so this is its usual save. accepted silently refused 400 with the prescription; the row is left as stored
An edit that re-sends its channel accepted, plus an advisory refused
An edit that moves it to email or sms accepted accepted, and the row edits normally from then on
Delivery rendered on no channel rendered on no channel (unchanged; no reader changes)

This 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 ObjectQL over SqlDriver (better-sqlite3) at 62bc129308, through scratch tests that are not committed. Every variant used the shipped object with only the rule or field swapped.

Shape format at error cross_field at error (chosen) select with options email / sms
New inbox insert refused, invalid_format, the authored message refused, rule_violation, the authored message refused, invalid_option, the generic "Channel must be one of: email, sms"
Insert with an import's kept option values refused refused admitted
Import row (insertMany) refused row refused row refused row, generic sentence
Stored inbox row: edit body or is_active accepted refused accepted
Stored row: re-send channel: 'inbox' refused refused refused, generic sentence
Predicate update over the stored row and an email row both written refused whole, nothing written both written
channel column varchar(64) varchar(64) varchar(255)
Read and delete of a stored row work work work

How the card's four readings fare at this base:

  • The select generic sentence: re-measured; it holds.
  • An import's keptOptionValues: re-measured; it holds.
  • The column width: re-measured; it holds.
  • objectui SelectField clearing an out-of-set value: not re-measured. It does not apply, because the field stays text.

One more source reading decides against the format spelling. At the current console pin cf153eb2e5, dirtyEditPayload (packages/plugin-form/src/sanitize.ts) sends only the changed fields on an edit save. So under format, an admin's Setup edit of a stored inbox row 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-level required / requiredWhen, not to object validation rules.

Decision analysis (four axes)

The three options are: A, format at error (it judges the write); B, a cross_field invariant (chosen); and C, a select.

  • 实际业务需求. I measured who writes and who reads this object. Writers are Setup admins (the Notification Templates grid, whose edit form sends only changed fields), API clients and imports. No shipped seed writes a row: the auth SMS seed is retired. No example writes one, and both dogfood upgrade fixtures (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 on email / 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.
  • 项目长远合理性. B uses an existing declared mechanism whose stored-row semantics the spec documents, with one rule and one list. A would leave the silent class this card exists to close. C moves the column type on deployed tables.
  • 防 AI 写错. B answers with a loud, authored prescription at every door, stored rows included. C's catalog sentence cannot carry the prescription, and kept option values bypass it.
  • 创业阶段不扩散. None of the options adds a gate or a surface; the new devDependency is test-only.
  • What B costs. A stored row for another channel can no longer be deactivated with is_active: false; it must be deleted or moved to email / 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 real ObjectQL over SqlDriver. It drives the real ObjectStackProtocolImplementation and mapDataError (the REST data door's classifier, which POST / PATCH /data/:object answer a thrown write with), and runs runImport for the import commit. Every refusal asserts the code (VALIDATION_FAILED), the status (400, or the row's httpStatus: 400), the finding (field: 'channel', code: 'rule_violation'), the message's first sentence and email_template, and that the row is unchanged.

  • New rows: five creates are refused (inbox, push, webhook, Email, sms), and the email and sms creates are accepted. Also covered: a kept-option-values insert is refused, a by-id move from email to inbox is refused, a by-id move between email and sms is accepted, and an import commit lands the sms row and fails the inbox row.
  • Stored inbox row (written through the driver):
    • an edit of body or is_active is refused;
    • a re-send of the channel is refused;
    • in a bulk update, that row fails while the email row lands;
    • a predicate update over a set holding it is refused and writes nothing;
    • a move to email is accepted, and so is a later edit;
    • the row is still read and deleted;
    • it still renders on no channel (NotificationTemplateStore.load returns null for both channels).

Package runs (under the shared lock):

  • pnpm --filter @objectstack/service-messaging run test: Test Files 55 passed (55) · Tests 621 passed (621), at 677e07e66e (after merging main). Before the merge: 54 files and 611 tests at 5be5094652.
  • pnpm --filter @objectstack/service-messaging run typecheck: exit 0. tsc --listFiles includes 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 with git checkout HEAD -- PATH, and the restore was proved by blob equality with HEAD (9675ee5837b5) and an empty git diff HEAD. The object is imported by a relative path from src, so no dist/ is involved.

Leg Mutation Red Green
A, the rule never refuses condition becomes false 12: every refusal (five creates, the kept-option-values insert, the by-id move, the import row, the stored-row edit, the re-send, the bulk row, the predicate update) 6: the positive twins, the email/sms move, the repair, read and delete, renders nowhere
B, the rule refuses everything condition becomes true 8: every acceptance (the email/sms creates, the email/sms move, the repair, the import's sms row, the bulk's email row, and the two cases that set up an email row) 10
C, a transition gate instead of the invariant condition becomes "out of set AND (insert OR the prior row was in set)" 4: exactly the stored-row refusals (the edit of another column, the re-send, the bulk row, the predicate update) 14: new-row refusals stay refused

Gates

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.

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 81 commands from 11 changed paths against merge base 73432299c. Each exit code was captured before any pipe.
  • First pass. 80 commands exited 0. pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET, because 8 workspace packages outside this diff's closure had no dist/. I built those 8 (pnpm turbo run build with 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".
  • Reconciliation. node scripts/pm/dispatch-gates.mjs --ran ran.list reads "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)".
  • Named readings:
    • 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:i18n exit 0;
    • pnpm check:test-source-alias exit 0. It was red once before the alias was added, naming @objectstack/metadata-protocol;
    • pnpm check:workspace-manifest-cycles exit 0;
    • pnpm check:nul-bytes exit 0.
  • Not run locally. test:repo (the vitest repo project) is CI's: this diff edits none of its tests and the derivation does not name it. pnpm lint is 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 no OS_TEST_SHARD and no path. Its closure was built first with pnpm 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.
  • The upgrade-harness fixtures (fixture-18.0, fixture-18.0.0) hold 0 sys_notification_template rows and keep channel varchar(64), so no fixture is stale under this change.

Acceptance notes

  • A pending changeset this change makes false if both ship together. .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.
  • ADR-0012's template line. ADR-0012 (sys_notification_template row, "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.
  • The console's rendering of the refusal. How the console displays the 400 finding on channel when an admin edits body was not measured in a browser. The finding names channel, so the field the admin must fix is the one marked.

Seat's append (domain:services seat 1, from the dev's patch-round report 6306289251 on #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 (commit 6974906eba). These are kept byte for byte:

  • the frontmatter ('@objectstack/service-messaging': patch);
  • the background paragraph;
  • the copy sentence ("The object's description, its empty state and the channel / subject help now say which channels use a row and that the inbox never does.");
  • the second sentence of Unchanged;
  • the whole What to do bullet.

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, the Clause-② 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.

  • BEFORE: "fix(service-messaging): a sys_notification_template saved for a channel other than email or sms is now warned about, because no channel ever sends it"
  • AFTER: "fix(service-messaging): the sys_notification_template copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does"

2. The warning paragraph. Five sentences become one; the paragraph's closing copy sentence is kept.

  • BEFORE: "The object now carries a channel_renders_template rule at severity: 'warning'. A write naming another channel is still accepted and stored, and it now reports the warning. A single-record create, clone or update answers it in the write answer's warnings (rule, severity, field: 'channel', code, message), and the server logs it once per write. Bulk updates and imports log it. The message points in-app content to an email template (email_template) named in a notify step's template."
  • AFTER: "This entry first added a channel_renders_template warning 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.

4. The first sentence of Unchanged.

  • BEFORE: "Unchanged. Every write that was accepted is still accepted, and stored rows are not read, rewritten or flagged by anything but a write that names their channel."
  • AFTER: "Unchanged. Nothing this entry describes changes which writes are accepted."

Code at the head is byte-identical to 677e07e66e, where the tests, the gates and the dogfood battery ran: git diff --stat 677e07e66e 6974906eba prints .changeset/24924-template-channel-email-sms.md | 8 ++++---- and 1 file changed, 4 insertions(+), 4 deletions(-). At the new head, node scripts/check-empty-changeset.mjs --base origin/main exits 1 with the DELIBERATE CORRECTION class line, and check-changeset-no-major, check-adr-0087-registration, check-changeset-fixed and check:nul-bytes exit 0.


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-messaging/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via rule_violation (literal, a string literal in a comment in NotificationTemplate))
  • content/docs/automation/email-templates.mdx (via sys_notification_template (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/data-modeling/seed-data.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/data-modeling/validation.mdx (via cross_field (literal, a string literal in validations), rule_violation (literal, a string literal in a comment in NotificationTemplate))
  • content/docs/kernel/runtime-services/sms-service.mdx (via sys_notification_template (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authentication.mdx (via sys_notification_template (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/kernel/error-handling.mdx (via rule_violation (literal, a string literal in a comment in NotificationTemplate))
  • content/docs/protocol/objectql/schema.mdx (via cross_field (literal, a string literal in validations))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/releases/v17/17-5.mdx (via cross_field (literal, a string literal in validations), rule_violation (literal, a string literal in a comment in NotificationTemplate))
  • content/docs/releases/v17/17-7.mdx (via sys_notification_template (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-messaging/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d991588ffcdedce1b79fc96ba2779c67c4f8d62 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e215fc18b2390b764932df3e717172585c2f71a — the merge of head 6974906eba3c466411790992a90f7349febba7bb into base 4d991588ffcdedce1b79fc96ba2779c67c4f8d62, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d991588ffcdedce1b79fc96ba2779c67c4f8d62 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset red on 6974906eba: DELIBERATE CORRECTION, by design · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-21T02:17Z

  • The gate: check-empty-changeset (the Check Changeset job's one failing step, read in job 118999388699). Every other step of that job passes: the self-tests, check-changeset-no-major (LEVEL AXIS no (narrowing) → minor) and check-adr-0087-registration (not-required (no-migration-prescription)).
  • The note: .changeset/24924-template-channel-email-sms.md, pending from PR fix(service-messaging)!: sys_notification_template.channel accepts only email and sms, the channels that read a template #24976 (service-messaging: sys_notification_template takes only the channels that read it (email, sms), by the maintainer's ruling D on #24770 #24924). This PR changes the behaviour it describes. A write naming another channel was warned about; it is now refused. So its headline, its warning paragraph, its Clause-② line and the first sentence of its Unchanged bullet would ship false beside this PR's entry. They are rewritten in commit 6974906eba, on the seat's order, and quoted BEFORE / AFTER in the PR body's "Corrected pending note" section. Restoring the file from the base would republish those sentences.
  • Why it may ride the queue red (SKILL.md, three conditions):
    1. The gate's own source names this class and its remedy: "DELIBERATE CORRECTION -> do NOT restore it; get it confirmed on the PR" (scripts/check-empty-changeset.mjs:127).
    2. The check does not run on merge_group: pr-automation.yml triggers on pull_request only (:41-43).
    3. This comment records the gate and the reason.
  • Confirmation: this PR changes code, so the same-head contract-tier review record confirms the correction sentence by sentence (landing-operations.md :14-17). It is running at CONTRACT_REVIEW_TIER on this head. The seat's ACCEPT follows only on its PASS.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6974906eba3c466411790992a90f7349febba7bb
Local-runs: none
Scope: hits none (the face is the Clause-②: no (narrowing) declaration, so the floor is every path of the diff): .changeset/24983-template-channel-refusal.md, .changeset/24924-template-channel-email-sms.md, packages/services/service-messaging/src/objects/notification-template.object.ts, packages/services/service-messaging/src/objects/notification-template-channel-refusal.integration.test.ts, packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts (deleted), packages/services/service-messaging/src/translations/en.objects.generated.ts, packages/services/service-messaging/src/translations/es-ES.objects.generated.ts, packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts, packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts, packages/services/service-messaging/package.json, packages/services/service-messaging/vitest.config.ts, pnpm-lock.yaml + packages/objectql/src/validation/rule-validator.ts, packages/objectql/src/engine.ts, packages/spec/src/data/validation.zod.ts, packages/spec/src/data/injected-system-columns.ts, packages/services/service-messaging/src/objects/index.ts, packages/plugins/plugin-security/src/claim-seed-ownership.ts, scripts/pm/clause2-line.mjs, scripts/check-empty-changeset.mjs

① Derived judgments

Inputs: card #24983 (body and every comment, rulings 6293376907 on #24770 and 6303865025 on #6015 read at source), PR #25028 (body, file list, net diff against main at the head), the head's check-runs. Nothing was built, run or re-run.

  • The rule's shape. channel_renders_template moves from a format rule at severity: 'warning' to a cross_field rule at severity: 'error', fields: ['channel'], condition !(record.channel in ["email","sms"]) built from TEMPLATE_CHANNELS, the only copy of the list. Read in rule-validator.ts: evaluateRule sends cross_field to checkPredicate over ctx.merged, which is { ...(previous ?? {}), ...data }; ruleNeedsPrior is true for cross_field, so the engine fetches the prior row; an error-severity violation throws ValidationError with code: 'rule_violation' on fields[0]. Right — this is the spec's documented invariant (validation.zod.ts, "Transition gate vs invariant": re-evaluated over the merged record on every write, no exemption for a stored violation, frozen not bricked).
  • New rows, at every door. Insert (engine.ts, the per-row evaluateValidationRules after validateRecordInScope; keptOptionValues reaches option membership only, never a rule; applyFieldDefaults runs first, so a create that omits channel is defaulted to email before the rule reads it), by-id update (previous: priorRecord), predicate update (multi: true, per matched row, any error-severity violation rejects the whole batch before updateMany writes), the data protocol's updateManyData (per record, continueOnError) and runImport (the import commit) all refuse a write that would leave channel outside the set; the REST door answers 400 VALIDATION_FAILED with one finding { field: 'channel', code: 'rule_violation' } through mapDataError. Right, and pinned by the 18-case file on a real ObjectQL over SqlDriver: five out-of-set creates (inbox, push, webhook, Email, sms), the kept-option-values insert, the by-id move, the import row, the bulk row, the predicate update — each asserting code, status, field, message head and that nothing moved.
  • Stored rows. A row already stored with another channel is refused on any edit that leaves it there (another column, a re-send of the channel, a bulk row, a predicate update over a set holding it), accepted on a repairing move to email or sms and edited normally after, still read, listed and deleted (a delete runs no validation rule), and rendered on no channel before and after (NotificationTemplateStore.load returns null for both channels). Nothing in the diff reads a stored row to rewrite or delete it. Right — the ruling's "refuses stored or new non-email/sms rows" at every write door, with no data move.
  • System writes. Error-severity rules are not gated for a system write (rule-validator.ts: only the advisory half is dropped on a write-back that touches injected columns alone — "ERROR-severity rules are deliberately NOT gated"), so an isSystem write is refused the same way. Right; every pin runs under isSystem: true.
  • Exactness. The membership test is exact and case-sensitive; the three readers key on the exact ids email / sms, so refusing Email and sms is right. A write that nulls channel is refused by required first; a stored null row (driver-written only, since required and defaultValue: 'email' predate the rule) is refused on edit whether in answers false or the predicate faults, because the evaluator is fail-closed. No pin covers a stored null row; no shipped path produces one. Noted, not a defect.
  • The column. channel stays Field.text, maxLength: 64, defaultValue: 'email': no DDL move, no select. Right, and the reasons for rejecting the other spellings hold in the cited code: a format rule returns early when the payload does not name the field (checkFormat), so under format at error Setup's edit of a stored row's body would pass — objectui's dirtyEditPayload (packages/plugin-form/src/sanitize.ts) returns only the changed fields, read in the sibling checkout; a select answers with the catalog sentence, admits an import's kept option values and widens the column.
  • Public surface. TEMPLATE_CHANNELS is now exported from the object module for the package's own tests; src/objects/index.ts re-exports NotificationTemplate by name only and package.json exports has the . entry alone, so no new symbol reaches the package surface. Right — nothing widens, which is what the no (narrowing) arm asserts.
  • Copy and translations. The channel help and the rule message now say the value is refused and carry the prescription (set the channel to email or sms, or delete the template; in-app content is an email_template named in the notify step's template). en regenerated; es-ES, ja-JP, zh-CN re-translated by hand for the two keys, read as faithful to the English. Right.
  • Tests. The 15-case advisory file is deleted whole — every warning case asserted that the write was accepted, which this PR refuses — and replaced by the 18-case file. The three ablation legs (never refuses / refuses everything / a transition gate instead of the invariant) reddened exactly the pins they should, the third leg exactly the four stored-row refusals. Right.
  • Test plumbing. @objectstack/metadata-protocol as a devDependency (a 3-line lockfile importer entry) and a vitest source alias are test-only, outside the claimed file surface and reported as deviations. Right; no runtime change, Validate Package Dependencies green.
  • Not in the diff, as the claim forbade: nothing under packages/spec/**; no change to which channels read a row (readers untouched, the renders-nowhere pin holds); no stored row rewritten or deleted; no ADR edit (the ruling asked for none on card ②). Right.

② Semver level

  • .changeset/24983-template-channel-refusal.md (new): @objectstack/service-messaging minor; a fix(...)!: headline; Clause-②: no (narrowing); the adr-0087 disposition marker not-required (no-migration-prescription) with its category reasoning (stored data rows are not metadata, so no conversion reaches them; nothing exported is removed or narrowed); the BREAKING banner; FROM / TO / Unchanged / operator-fix bullets. A platform object's write accept set narrows, stored rows included, and no ADR-0087 conversion can carry it, so under the amended 「甲」 rule 2 (6303865025) it ships now as a BREAKING minor. Right: minor not major (the launch-window guard, Check Changeset step 15, green), the disposition reads as the exemption (step 13 green; the notice annotation on the file quotes it), and no (narrowing) is the arm clause2-line.mjs defines as "NOT a widening, but breaking". The PR body, the claim comment and the changeset carry the same line. The (narrowing) arm is also what reviewFace reads for the narrowing face — on ANY of the committed changesets — so this record is served at CONTRACT_REVIEW_TIER.
  • .changeset/24924-template-channel-email-sms.md — the DELIBERATE CORRECTION. This PR rewrites the pending note from PR fix(service-messaging)!: sys_notification_template.channel accepts only email and sms, the channels that read a template #24976 (service-messaging: sys_notification_template takes only the channels that read it (email, sms), by the maintainer's ruling D on #24770 #24924). Check Changeset is red on both runs on this head (check-runs 118998377496 and 118999388699) at step 12 only, "Reject an empty-frontmatter changeset added by this PR", which is check-empty-changeset.mjs's foreign-changeset refusal; steps 11, 13, 14 and 15 are green on both. The annotation on the file names the two classes; this is the correction class, not a collision: this PR's own note has its own filename. Each rewritten sentence, BEFORE (origin/main) and AFTER (the head):
    1. Headline. BEFORE: "fix(service-messaging): a sys_notification_template saved for a channel other than email or sms is now warned about, because no channel ever sends it" — false once both ship (refused, not warned). AFTER: "fix(service-messaging): the sys_notification_template copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does" — true (the fix(service-messaging)!: sys_notification_template.channel accepts only email and sms, the channels that read a template #24976 copy stands, and this PR only extends the channel help). Confirmed.
    2. Background paragraph — kept byte for byte; true as history.
    3. The warning paragraph. BEFORE sentences 1–4 — "The object now carries a channel_renders_template rule at severity: 'warning'.", "A write naming another channel is still accepted and stored, and it now reports the warning.", "A single-record create, clone or update answers it in the write answer's warnings (…), and the server logs it once per write.", "Bulk updates and imports log it." — each false once both ship. Sentence 5, "The message points in-app content to an email template (email_template) named in a notify step's template.", described the warning's message, which no longer exists as a warning; its content survives in the kept What-to-do bullet and in service-messaging: refuse sys_notification_template rows for a channel other than email or sms (the 19.0 half of ruling D) #24983's entry, so no fact is lost. Sentence 6 (the copy sentence) is kept and true. AFTER: "This entry first added a channel_renders_template warning 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." — true once both entries ship in one release. Confirmed.
    4. The Clause-② line. BEFORE: "Clause-②: no — nothing a write could send before is refused now; a save naming another channel gains one advisory in the write answer's existing warnings." — false once both ship. AFTER: "Clause-②: no — this entry now describes copy only (the object's description, its empty state and the field help); the narrowing of what a write may send is service-messaging: refuse sys_notification_template rows for a channel other than email or sms (the 19.0 half of ruling D) #24983's, declared in its own entry." — true, and no with no arm is the right reading for a copy-only entry: clause2-line.mjs reads an absent arm as no direction, and reviewFace takes the narrowing face from the arm on any changeset, so this line cannot demote the face. Confirmed.
    5. Unchanged, first sentence. BEFORE: "Every write that was accepted is still accepted, and stored rows are not read, rewritten or flagged by anything but a write that names their channel." — false. AFTER: "Nothing this entry describes changes which writes are accepted." — true. The second sentence ("email and sms rows, how every channel renders, and the column and its width are unchanged.") is kept and still true under this PR. Confirmed.
    6. What to do — kept byte for byte; still true (the delete remedy is also this PR's prescription).
    7. Frontmatter '@objectstack/service-messaging': patch — kept; right for a copy-only entry.
      Verdict on the correction: every sentence this PR made false was rewritten, the AFTER is true once both entries ship in one release, and no true sentence was lost. The correction is CONFIRMED; the step-12 red is the designed red, and the note must not be restored from the base.
  • Clause-②: no (narrowing) on the PR body matches the changeset and the diff. Right.

③ Boundary flags

  • The dev's open question (stored-row semantics; A format at error, B cross_field invariant, C select; B shipped). Answered: B. "Refuses stored rows" in 6293376907 is met honestly, as a write-path refusal: the invariant refuses every edit that leaves such a row out of the set, the row is left exactly as stored, and nothing in the diff reads, rewrites or deletes a stored row — the claim's ⛔ and the card's "no stored row is rewritten or deleted without a measured proposal brought back first". Reading the ruling as a purge of stored rows would be the data move the card forbids. A is wrong because Setup's edit form sends only the changed fields, so the stored half of the ruling would hold at no door the console uses; C is wrong for the catalog sentence, the kept-option-values admission and the column move. The two costs the dev names are judged acceptable, not escalated: (i) such a row can no longer be deactivated with is_active: false — deactivation changes nothing any reader sees, since no channel reads the row, and the refusal names both remedies (move it or delete it); (ii) a predicate (multi: true) update over a set holding such a row writes no row — the engine's existing all-or-nothing rule for every error-severity violation on a bulk write, with the failing record id appended to the message, and no shipped platform caller issues one on this object: claimSeedOwnership runs only on objects with an owner_id field (hasOwnerField), and sys_notification_template declares none and is injected none (injected-system-columns.ts: a sys_* or managedBy object gets no ownership anchor); Setup's grid bulk edit is updateManyData, per row with continueOnError.
  • Dev flag — the console's rendering of the channel finding when an admin edits body (not browser-measured). Answered: the finding names channel and carries the whole prescription, so the field the admin must fix is the one marked. Not escalation-worthy; a dogfood follow-up at most.
  • Dev flag — ADR-0012's "MJML for email, JSON for others" row. Answered: stale prose about readers that never existed; the ruling asked for no ADR edit on card ②. Carrier none, noted; the seat may file a docs nit.
  • Dev flag (round 0) — the pending 24924 note made false. Answered: corrected in patch round 1 and confirmed sentence by sentence above.
  • Deviations. Three files outside the claimed surface (package.json, pnpm-lock.yaml, vitest.config.ts) are test-only and the lockfile hunk is the 3-line importer. The translated-locale values were hand-edited, which AGENTS.md allows, and CI's lint farm carries the i18n gates. The dev rewrote five sentences the seat did not name; all five were false the same way and are judged above. Accepted.
  • Landing condition. The corrected note is true on the premise that both entries ship in one release. If a release were cut from main before this PR lands, the release PR would consume the note and this PR's modification would conflict on it; the seat re-reads at landing. No escalation.
  • Premise. The card was re-timed from pm:on-hold to pm:queue under the amended 「甲」 (6304791651): the refusal ships now as a BREAKING minor. Still valid.
  • Check-runs on the head. 49 check-runs, all complete, read once every run had concluded. Latest run per name: every name is success except Check Changeset, which is failure on both of its runs (118998377496 and 118999388699) at step 12 only — the DELIBERATE CORRECTION confirmed in ② — with steps 11, 13, 14 and 15 green on both; and eight skipped names, every one on scripts/pm/check-expected-skips.mjs's roster: 18.x upgrade harness (opt-in) and Packed-tarball smoke (opt-in) (label opt-ins); Console Pin Gate, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL) and the Dogfood Regression Gate matrix shard under its raw template name (full-battery-only jobs; the aggregate Dogfood Regression Gate is success); and Auto Label and Check PR Size on the second, edited-event run alone (their first runs are success). Test Core 1/6 to 6/6 and its aggregate, every Lint · and Type Check · job, Lint & Repo Gates, TypeScript Type Check, Build Core, Build Docs, Governed Surface Queue Guard, Validate Package Dependencies, filter and the four claim guards are success. The PR's head is still this sha.
  • Nothing is escalated.

Implemented-by: claude/issue-24983-template-channel-refusal
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS

Posted 2026-10-21T02:37Z · contract review at the narrowing face, PR #25028, card #24983

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-21T02:39Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 4d991588ff, not against the reports (os-dev reports 6306229142 and 6306289251 on #24983).

Head under review:

6974906eba3c466411790992a90f7349febba7bb
  1. Shape.
    • Draft, base main, first line Fixes #24983.
    • The title is fix(service-messaging)!:, and Clause-②: no (narrowing) is at line start in the body and the new changeset.
    • 11 files, +353/−247, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean, and the corrected .changeset/24924-… note is still pending on main.
  2. Review of record.
    • The face is narrowing at CONTRACT_REVIEW_TIER.
    • The isolated contract review 6306621951 reads PASS on this exact head (Served-tier: CONTRACT_REVIEW_TIER, Local-runs: none). It judges the accept-set change at every door, the semver level and every boundary flag, and it escalates nothing.
  3. The fix, read in the code.
    • channel_renders_template is now a cross_field invariant at severity: 'error'.
    • It is built from TEMPLATE_CHANNELS, the one copy of the list.
    • It is evaluated over the merged record on insert, by-id update, predicate update, updateManyData and the import commit.
    • A stored out-of-set row is frozen, not bricked: it can still be read, listed and deleted, and a repairing move to email/sms is accepted. No stored row is rewritten or deleted.
    • packages/spec and packages/objectql are untouched.
  4. The dev's open question (stored-row semantics) is answered B by the review of record, and the seat agrees. The two named costs are judged acceptable: a stored out-of-set row cannot be deactivated, and a predicate update over a mixed set writes no row.
  5. DELIBERATE CORRECTION carried red (SKILL.md, three conditions, plus landing-operations.md :14–17).
    • The note: .changeset/24924-template-channel-email-sms.md. Its headline, warning paragraph, Clause-② line and the first sentence of Unchanged were made false by this PR, and are rewritten in 6974906eba.
    • The confirmation: the same-head review 6306621951 judges each rewritten sentence BEFORE/AFTER, and finds that the correction is CONFIRMED and no true sentence is lost.
    • ① The source names this class red by design: check-empty-changeset.mjs:127.
    • ② The check does not run on merge_group: pr-automation.yml runs on pull_request only.
    • ③ The gate and the reason are recorded on this PR in 6306367946.
  6. Pins and ablation.
    • notification-template-channel-refusal.integration.test.ts has 18 cases on a real ObjectQL over SqlDriver, through the data protocol and mapDataError. It replaces the whole advisory file this PR retires.
    • Ablation legs: A, condition false (12 red); B, condition true (8 red); C, a transition gate (exactly the 4 stored-row pins red).
    • Each restore was proven by blob equality.
  7. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip, except Check Changeset, which is red at the check-empty-changeset step only (item 5).
    • The successes include Test Core 1–6/6, Build Core, TypeScript Type Check, the Lint lanes, Validate Package Dependencies, Governed Surface Queue Guard and the claim guards.
    • Auto Label and Check PR Size read skipped after the seat's body append, which is on the expected-skips roster.
    • Locally, 81 of 81 derived families exited 0, and --ran reads 0 NOT MEASURED.
    • service-messaging: 621 tests passed.
    • dogfood_battery (docs(os-dev, pm-dispatch): a breaking PR runs the full dogfood battery on its final head; the review checklist reads the field before arming #24917): 677e07e66e, VERDICT command-exit 0, Test Files 284 passed | 3 skipped (287), Tests 2361 passed | 18 skipped, no failures. The full battery ran with no shard and no path. The head differs from 677e07e66e only in .changeset/, which git diff --stat proves, so the battery stands for this head.
  8. Accepted deviations.
    • Test-only files outside the claimed surface: package.json devDependency, vitest.config.ts source alias, and the 3-line lockfile importer, with unrelated esbuild hunks discarded and --frozen-lockfile green.
    • Hand-edited translated values of two keys.
    • Five sentences of the pending note rewritten beyond the three the seat named, all false the same way and confirmed by the review.
    • Six pushes plus one, and one merge of main.
    • Model-free trailers.
  9. Out-of-scope disposition.
    • ADR-0012's "MJML for email, JSON for others" row is stale prose about readers that never existed. It has no reach and no carrier, so no card.
    • The console's rendering of the channel finding was not browser-measured. The finding names channel and carries the prescription.
  10. Landing clause.
    • Tier S: not governed, under the human-merge threshold, a BREAKING minor under the launch-window convention.
    • Next step: ready + auto-merge through the relay, carrying the designed Check Changeset red.
    • Landing condition from the review: this PR must land before any release consumes .changeset/24924-…. The note is pending at this ACCEPT, and the seat re-reads it if the queue kicks the PR.
    • A push resets this reading.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 02:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 02:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit d68aab6 Oct 21, 2026
48 of 50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24983-template-channel-refusal branch October 21, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-messaging: refuse sys_notification_template rows for a channel other than email or sms (the 19.0 half of ruling D)

2 participants