ADR-0034: Notification realization — in-process domain events + system-context fan-out¶
- Status: accepted
- Date: 2026-07-02
- Deciders: Nicolas Burri
- Refines: ADR-0019 (task/notification model, "event-driven; modules emit, notification consumes"), ADR-0031 (the operation-scoped SECURITY DEFINER pattern this reuses)
- Refined by: ADR-0035 (the fan-out contract as shipped — gate semantics, count disclosure, audit protocol)
Context¶
ADR-0019 fixed the model (task vs notification, scope-keyed + RLS, operational-not-record) and the V019 schema realizes it. Two realization questions remained open (staged in planning/notifications-realization.md):
- F-N1: how life-cycle events reach the notification module without coupling the emitting modules to it.
- F-N2: how derived scopes learn that their base version released a new version or was revoked
(REQ-DOC-012 / REQ-LC-013) — a system-triggered, cross-tenant fan-out: the base actor cannot
see the derived scopes under RLS, and content RLS (
with check) forbids them writing there.
Decision¶
F-N1 — synchronous in-process domain events, same transaction¶
A small DomainEventPublisher (lifecycle module) with typed events (VersionSubmitted,
VersionApproved, VersionRejected, VersionReleased, VersionRevoked, VersionCancelled).
LifecycleService publishes after each transition inside the transition's transaction, passing
the transaction's DSLContext; handlers (the notification module's task generation) write in that
same transaction. The api module wires handlers to the publisher at startup — the document module
never references the notification module (ADR-0005 boundaries hold; no broker, no new
infrastructure).
Consequences of "same transaction": task/notification generation is atomic with the transition (no crash window between a submit and its approval task), and a handler failure rolls the transition back — acceptable while the only handler is task generation, revisit if slow/fragile handlers appear. The event types and publisher are shaped so a durable outbox dispatcher can replace the synchronous registry later (rejected for now: heaviest option, and no delivery-guarantee requirement yet). Direct service calls were rejected as coupling lifecycle to notification against ADR-0019's intent.
F-N2 — system-context fan-out via a self-gating SECURITY DEFINER function¶
On VersionReleased/VersionRevoked of a version whose scope may_be_derive_source, a SECURITY
DEFINER function (lqms_fanout_source_event, V020) resolves the affected derived versions over the
relationship table (derived-from) and the system — not the actor — inserts one
SOURCE_UPDATED / SOURCE_REVOKED task per affected derived document into the derived scope,
addressed to the derived version's author. This follows the V016/V017 pattern (owner-owned DEFINER
function bypasses forced RLS) and self-gates:
- the source version must actually be in the state matching the event (
RELEASED/REVOKED), - its scope must be a derive source (
may_be_derive_source), - the calling user must be authorized for the source version's scope (via
lqms_authorized_scope_ids()/lqms_current_user_id()) — so the only way to write into a foreign scope is a real release/revoke of a base version the caller legitimately acted on, - inserts are idempotent per (scope, document, type, open) — re-releasing does not stack duplicate open tasks.
Information-flow bound: the fan-out writes only what the derived scope already knows from its own provenance row (the source document reference + the event kind). Nothing about the base scope's other content crosses the boundary, and the actor learns nothing about the derived scopes (the function returns only a count). The fan-out is audited in the source scope.
The polling alternative (derived scopes read a "source updates" view) was rejected: it needs an equivalent DEFINER read surface anyway, adds UI polling, and loses the inbox-native push.
Consequences¶
- Mandator separation stays intact: no standing cross-tenant readability or writability is added; the single elevated write path is operation-scoped, self-gated, and idempotent (REQ-SEP-010 spirit).
LifecycleServicegains a publisher dependency (no-op default keeps existing tests untouched).- The notification module depends on
lifecycle(events + policy resolution) — a new but ADR-0005-legal edge;documentstill has no knowledge ofnotification. - Reminders/escalation (REQ-NOT-004), email channel (REQ-NOT-007) and periodic-review/disposal tasks (REQ-NOT-008) remain out of scope here (per the realization plan) and may motivate the outbox later.