Repository navigation
fix(service-messaging): resolve a notification template in the recipient's organization - #24930
Conversation
…ent's organization sys_notification_template is organization-owned (ADR-0131). The template store now keys its read by the delivery's organization, the spelling the preference resolver scopes its rows by, and the email and SMS channels hand it the delivery's organization. A call that names no organization reads no row and renders the producer's own title and body. Pins: the store's read shape (template-renderer.test.ts) and, on a real ObjectQL over SqlDriver holding two organizations under isolated and group, the email and SMS channels each rendering the row of the delivery's own organization (template-organization.integration.test.ts), with the single posture's sole organization as the preservation control. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…mplate-read-organization Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 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 8c33c0c304f215d852ffd7587e1582f3a6c277a8 && git checkout 8c33c0c304f215d852ffd7587e1582f3a6c277a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 71696121a64d8bd057fac969a72a84295890ed37 5abbc0802575de00ea74577e9dc15ac4a0105891 && git checkout -B drift-repro 71696121a64d8bd057fac969a72a84295890ed37 && git merge --no-ff 5abbc0802575de00ea74577e9dc15ac4a0105891
node scripts/docs-audit/affected-docs.mjs --json 71696121a64d8bd057fac969a72a84295890ed37
|
席位核对REWORK (patch round 1) · #24865 · The messaging half matches the card.
Seat ruling on which organization an auth SMS reads in (not a product question; decided by governing text, with a falsifiable premise):
The out-of-scope note (the emit hands the preference filter Generated by Claude Code |
…mplate-read-organization Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…the send resolves to sys_notification_template is organization-owned (ADR-0131). The phone OTP and invitation SMS carry no organization of their own, so AuthManager.renderPhoneSmsBody hands the resolver tenancy.defaultOrgId() (ADR-0093 D3) and every template read in phone-sms-texts.ts is keyed by it: the Default Organization's row under single; under a requested wall no organization resolves, no row is read and the built-in text is sent. Pins: phone-sms-texts.test.ts (read shape, another organization's row, no organization) and, through the real AuthManager, tenancy service and an ObjectQL over SqlDriver holding two organizations, phone-sms-template-organization.integration.test.ts per posture. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
The store double stamps each row with its organization, and the spread narrowed the inferred element type to that one key, so the retired-read oracle's `.body` access failed check:test-typecheck (TS2339). Annotating the array as `Row[]` restores the row type; the double's behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…d organization The AuthManager test that pins "a tenant row overrides the built-in text" now composes a tenancy service answering the Default Organization and serves the row only to a read keyed by that organization, the way the phone SMS resolver reads since the template row is organization-scoped. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
席位核对REWORK (patch round 2) · #24865 · Patch round 1 is accepted on its content:
The seat applied the PR-body edits the dev handed over ( One item is owed before ACCEPT:
Docs, carried, not in this PR. Generated by Claude Code |
…nizationId on NotificationTemplateStore.load NotificationTemplateStore is exported from the package's `.` entry, so the new optional parameter widens the published surface: the changeset declares Clause-② yes (widening), names the parameter, and keeps the no-organization answer for direct callers. The plugin-auth changeset is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The face is the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · #24865 ·
Generated by Claude Code |
Fixes #24865
Clause-②: yes (widening) —
NotificationTemplateStore.load(exported from@objectstack/service-messaging's.entry) gains the optionalorganizationIdparameter. Corrected fromnoby thedomain:servicesseat at patch round 2.What this changes
sys_notification_templateis organization-owned (ADR-0131: organization ownership is total; the table carriesorganization_idand the platform-object tenancy census lists it). The messaging template store now resolves the row in the recipient's organization:NotificationTemplateStore.load(topic, channel, locale, organizationId)(packages/services/service-messaging/src/template-renderer.ts) keys its readwhere: { organization_id }underFAN_OUT_SYSTEM_CONTEXT, the spellingPreferenceResolver.loadRowsalready scopes its rows by. No new read primitive.email-channel.ts,sms-channel.ts) hand the store the delivery's own organization,Notification.organizationId. The inline fan-out and the outbox dispatcher both carry it there from the event row.null, so the channel renders the producer's own title and body (the renderer's documented no-template fallback). Reasoning: there is no row with no organization (ADR-0131 D1), so there is no row such a delivery could own. Measured on the same two-organization harness, the composed fan-out never hands a channel such a delivery: an emit that names no organization is refused (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED) underisolatedandgroup, and undersinglewith one organization the event row carries that organization (ADR-0131 D9). So this branch changes nothing a composed deployment renders. It closes the shape for direct callers of the exported store.The
plugin-authreader (patch round 1)AuthManager.renderPhoneSmsBody(the only change inauth-manager.ts) handsphone-sms-texts.tsthe organization a deployment-level act resolves to,tenancy.defaultOrgId()(ADR-0093 D3). Every template read there (the active-row read and the existence read) is keyedwhere: { organization_id }.single, the Default Organization's row decides the text.isolatedandgroup, no organization resolves, no row is read, and the built-in text is sent. The per-organization template door stays closed under a wall (ADR-0131 §1.4, D6).This follows the seat ruling
6295270052. Its premise was measured on the real tenancy service, undersingle,isolatedandgroup, enforced and degraded.phone-sms-texts.test.ts, andphone-sms-template-organization.integration.test.ts(the realAuthManagerinvitation and OTP sends, the real tenancy service, two organizations).HEAD..changeset/24865-auth-sms-template-organization.md(@objectstack/plugin-authpatch).phone-sms-seed-retired.test.ts(including theRow[]type fix CI caught),find-envelope-limb-removal.test.tsandauth-manager.test.ts.Reachability, as an abstract statement
Measured at
a7199ef97on a realObjectQLoverSqlDriver(better-sqlite3) holding two organizations, under theisolatedand thegroupposture: before this change, an email or SMS notification delivery to one organization's recipient could render a template row another organization authored, and so could the phone OTP and invitation SMS.singleenforces no organization wall.Pins
template-renderer.test.ts, block "a template is resolved in the recipient's organization": every locale rung of the read is keyed by the organization handed in; a call that names no organization reads nothing.template-organization.integration.test.ts, on a real engine with two organizations underisolatedandgroup: each organization's delivery renders its own organization's row, on email and on SMS; an organization that authored no row renders the producer's own title and body; a delivery that names no organization renders the same. Preservation control undersingle: the sole organization's row still renders. Each case first asserts the organization every fixture row is stored with, so no pin passes on a mis-stamped row.email-channel.test.ts,sms-channel.test.ts,system-context.pin.test.ts): the deliveries that render a template row now name their organization, as every composed delivery does, and the fakes matchorganization_id.Ablation (one-off; the fix was committed first, each leg via
scripts/ablation-replace.mjs, restored with blob ==HEADandgit diff HEADempty)src/organization_idterm removed from the readisolated+group), and three store unit pinsorganization_idterm whose value isundefinedmatched no row (it was not dropped as an absent term). Fewer reds than expected, reported as observed; leg C is the forbidden behaviour spelled the way the package spells itisolated+group) and the unit pinemail-channel.ts, then fromsms-channel.tsisolated+group) and thesinglepreservation pinSubject resolution: the pins import
template-renderer.ts,email-channel.tsandsms-channel.tsrelatively fromsrc/, so nodist/sits between a mutation and the run.Tests
Patch round 1, head
386f5d4bd:plugin-auth: typecheck exit 0, includingcheck:test-typecheckwith the ledger unchanged; vitest 172 files, 3287 passed.service-messaging: typecheck exit 0; vitest 53 files, 593 passed.Round 0, below, is kept for the record.
Round 0 (at
bb37f9c62, the fix merged withorigin/mainat19484af53)turbo run build --filter=@objectstack/service-messaging^..., 15 tasks successful.pnpm --filter @objectstack/service-messaging typecheck: exit 0.tsc --listFilesincludestemplate-organization.integration.test.tsandtemplate-renderer.test.ts.pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2: 53 files, 593 tests passed. The same numbers were read at8ed3c0638before the merge.@objectstack/service-messaging. The public surface ofNotificationTemplateStore.loadgains an optional fourth parameter, declared above asClause-②: yes. Its one in-repo consumer is this package (messaging-service-plugin.tsand the two channels), so no importer owes a test.Gates
Local subset at
bb37f9c62:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derives 69 commands. Each was run with its exit code captured before any pipe: 69 x exit 0. Reconciliationdispatch-gates --ran:69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.An earlier round at
8ed3c0638had twoPREREQUISITE NOT MET(exit 3) results,check:dual-build-cjs-loadsandcheck:i18n, because their build prerequisites were missing. Both measured green in the round above (check-i18n-bundles: OK (10 package(s) ...); service-messaging bundles in sync).The families CI runs (
Lint & Repo Gates,TypeScript Type Check,Test Core,Build Core, and the rest) are read from this PR's check-runs. They were not re-run locally.The branch was current with
origin/mainat19484af53when this PR opened. Two later commits onmaintouch neitherservice-messagingnor the engine, driver or spec its pins run on.Acceptance notes
.changeset/24865-template-recipient-organization.md,@objectstack/service-messagingpatch. It states the fourth argument and the no-organization answer for direct callers of the exported store.MessagingService.emithands the preference filter and the recipient resolverinput.organizationId(what the producer passed), not the event row's organization the delivery family follows. The two differ only when the producer names none, and under a wall that emit is refused, so no walled delivery reaches them unscoped. Carrier: none.content/docs/kernel/runtime-services/sms-service.mdxdescribes the SMS channel rendering the(topic, 'sms', locale)row without naming the organization. It is not wrong, so it is left unchanged.Generated by Claude Code