Notification Fanout
Table of contents
Summary
Notification Fanout connects an event to everyone who wants to hear about it. It combines two simpler patterns: one that records who is interested in which kind of event (Subscription) and one that creates a delivery record for one recipient and tracks whether it succeeded (Notification). Neither can do the job alone — the first knows who is interested but cannot deliver, the second can record a delivery but cannot decide who should get it.
When an event fires, the composition asks the subscription list who is currently subscribed to that event’s topic and then creates one delivery record for each of them. The subscriber list is fixed at the moment the event fires — someone who cancels a split second later still gets a record for this one, someone who joins later does not — and a failure creating one person’s record does not stop the others.
Combining the two patterns guarantees that every current subscriber gets exactly one record per event (or the failure is named in the result, never hidden), and that all records from one event carry the same content. The composition keeps no state of its own; audit, replay, and deduplication are added by further patterns layered alongside it.
The most common uses are compliance and policy-change broadcast systems where every subscribed officer must receive a delivery record that can be audited; product and project management platforms where task events notify all interested team members; and any distributed system where an action in one domain must propagate to a variable number of downstream consumers without the emitting component knowing who they are.
Intent
Subscription and Notification are freestanding atoms (specs that can be specified without naming any other pattern): Subscription records who is interested in what; Notification records whether a piece of information reached a recipient. Neither knows about the other. What neither can do alone is answer the question that arises when an event fires: for every subscriber currently Active on this scope, produce a delivery record. That is the fanout operation — and it is a composition concept, not an atom concept.
The composition is structurally simple: one query (Subscription.subscribers_for) followed by N creates (Notification.create, one per returned subscriber). Its architectural significance is that it is the first place in the library where a single trigger produces a variable number of effects — N notification records, where N is the count of Active subscribers at trigger time. This is not a single transition with N side effects; it is a directed invocation graph (a representation of all the calls the composition makes and their dependencies — one query feeds N independent creates) with one query edge and N create edges. The fan-out is the composition; the atoms remain closed single-transition state machines.
The composition makes two architectural commitments explicit. First, the subscriber set is determined once at trigger time — a subscriber who cancels after the subscribers_for query executes still appears in that invocation’s fanout; a subscriber who joins after it does not. Second, fan-out failures are per-recipient and non-aborting — a storage-failure creating one recipient’s notification record does not cancel the creates for remaining recipients. Both commitments follow from the boundary rule for parallel composition: no rollback guarantee exists across independent create operations.
Composes
- Subscription — provides the Active subscriber set and the
subscribers_for(event_scope)query surface. The composition reads but never writes the subscription store. - Notification — provides the per-recipient delivery record and the
create(recipient_ref, payload)action. The composition creates one Notification record per subscriber returned by the Subscription query.
Composition logic
Composition state
None. Notification Fanout has no persistent state of its own beyond its constituents’ stores. The Subscription store owns who is subscribed; the Notification store owns what was created and its delivery outcome. The composition is a stateless interpreter of a directed invocation graph over these two stateful atoms.
If a system needs to record that a particular event triggered a particular fanout — for audit, replay, or deduplication — it composes Event Log or Duplicate Prevention alongside this composition. Those concepts do not belong to the bare fanout mechanism.
Primitive policies
Composition-boundary validation for Fanout’s two inputs:
event_scope— must be non-null and non-empty string (rejection:invalid-request). The atom treats the scope value as opaque; no normalization, no case folding, no length cap imposed at this layer. Comparison is exact-match when passed toSubscription.subscribers_for. Validated before any id is generated or constituent called.payload— must be non-null (rejection:invalid-request). Content is opaque and passed to eachNotification.createcall unchanged. Schema validation, size limits, and content restrictions belong to the composing system before calling Fanout.
The load-bearing wiring decision
The decision the composition exists to enforce: when a fanout invocation fails for some subscribers, the invocation continues for the remaining subscribers and names the failures in its result rather than aborting.
Principle. A subscriber fanout is a parallel operation: each recipient’s delivery record is independent. The composition must choose between all-or-nothing (abort on first failure, guarantee consistency) and continue-and-name-failures (deliver to the reachable set, surface the unreachable set). The composition chooses the latter.
Likely objection. “Shouldn’t a regulated notification fanout guarantee every subscriber was reached, or none?” The all-or-nothing design would guarantee no subscriber receives a notification when the store is briefly unavailable for any single subscriber — regardless of the event’s stakes. This is almost never the right tradeoff: it trades guaranteed delivery to the reachable majority for consistency with the unreachable minority.
Mechanism. Parallel composition carries no rollback guarantee. Each Notification.create call is independently committed. The composition has no transactional boundary spanning the N creates; providing one would require distributed transaction semantics and would serialize what is fundamentally a parallel operation. The Failed list is the pressure valve: it makes the tradeoff explicit rather than silent. The composing system receives the failure information and applies its own policy — accept-the-loss for low-stakes events, retry or escalate for high-stakes ones.
Result. Fanout coverage (Invariant 1) guarantees that every subscriber is accounted for in Created or Failed. No subscriber is silently missed. The composition’s job is mechanism; policy lives in the caller. For regulated deployments, the Failed list combined with Event Log and Audit Trail gives the auditor a complete, attributed record of which subscribers were reached and which were not — the structural answer a regulator can verify from records alone.
Action wiring
The composition exposes a single action:
Fanout — (Projected contract: fanout(event_scope, payload) → {fanout_id, created: [notification_id, ...], failed: [subscriber_ref, ...]} | rejected(invalid-request | subscribers-unavailable))
- Validate inputs:
event_scopemust be non-empty;payloadmust be non-null. If either condition fails, returnrejected(invalid-request). No id is generated; no subscriber query is made; no notification records are created. This mirrors the constituent atoms’ own validation:Subscription.subscriberejects emptyevent_scope;Notification.createrejects nullpayload. - Generate the Fanout Id — an opaque (system-generated with no meaningful content), invocation-unique identifier (a direct effect:
entropy.generate()). This id is the correlation handle for this invocation. When Event Log is composed in, the caller uses Fanout Id as the log entry’s reference, binding the invocation record to its subscriber list and creatednotification_ids. Without Event Log, Fanout Id is ephemeral — returned to the caller for transient correlation but not persisted by the composition. - Call
Subscription.subscribers_for(event_scope).- If the subscription store is unavailable (infrastructure failure at the read step), return Subscribers Unavailable. No notification records are created. Fanout Id is not returned on rejection — the invocation did not complete.
- If the result is an empty list, return
{fanout_id, created: [], failed: []}. The fanout is complete; no subscribers are currently Active for this scope.
- For each
subscriber_refin the returned list, callNotification.create(subscriber_ref, payload).- If
createreturns anotification_id, add it to the Created list. - If
createreturnsrejected(storage-failure), addsubscriber_refto the Failed list. Continue to the next subscriber; do not abort the fan-out. - If
createreturnsrejected(invalid-request), this indicates a structural inconsistency —subscriber_refis non-empty (it was returned from the subscription store) and payload passed input validation in step 1. Treat as equivalent tostorage-failurefor that subscriber; add to Failed. Continue.
- If
- Return
{fanout_id, created: [notification_id, ...], failed: [subscriber_ref, ...]}.
The order of create calls across subscribers is not guaranteed. Parallel execution is permitted provided the implementation guarantees each Notification.create call is independently committed — no shared transaction boundary across the N creates. The result’s Created and Failed lists are unordered.
The fan-out continues through failures. A storage-failure on one subscriber’s Notification.create does not abort the fan-out for remaining subscribers. This follows directly from the composition boundary rule: parallel composition carries no rollback guarantee. A subscriber not in the Created list is one for whom no delivery record exists; the composing system is responsible for inspecting the Failed list and deciding whether to retry.
Retry semantics
A caller who receives a non-empty Failed list and wishes to retry has two options. First: call Notification.create(subscriber_ref, payload) directly for each subscriber_ref in the Failed list — this retries exactly the failed creates without re-querying the subscriber set. Second: call Fanout again — this re-queries subscribers_for, which may return a different subscriber set if subscriptions have changed in the interim. The first option is correct when the caller needs to deliver to exactly the original fanout’s subscriber set; the second is correct when delivering to the current Active set is the right behavior. Callers who need at-most-once fanout semantics across retries should compose Duplicate Prevention to guard the Fanout call itself; see Edge cases.
All entries in the Failed list are treated as retry-eligible regardless of the underlying rejection reason — storage-failure and the structurally-inconsistent invalid-request case (see action wiring step 3) are collapsed into a single “delivery did not record” outcome. A retry for a structurally-inconsistent subscriber_ref will fail again with the same rejection; persistent failure for a specific subscriber_ref across multiple retries indicates a structural inconsistency that the composing system must resolve out-of-band (the subscriber_ref is malformed, the notification store has rejected the payload shape, etc.). The composition does not surface the underlying reason — callers needing reason-level diagnostics compose Event Log to capture each Notification.create outcome at the call site.
Composition-level invariants
These invariants emerge from the composition. Neither constituent atom carries them alone.
- Invariant 1 — Fanout coverage. For any
fanout(event_scope, payload)invocation that returns a result (notrejected), exactly oneNotification.createcall is attempted for eachsubscriber_refreturned bySubscription.subscribers_for(event_scope)at the time of the query. No subscriber is skipped; no subscriber outside the query result receives a create call. The Created and Failed lists together account for every subscriber in the query result:|created| + |failed| = |subscribers_for result|. - Invariant 2 — Payload consistency. All Notification records created in a single Fanout invocation carry the same payload. A subscriber cannot receive a different payload than another subscriber from the same invocation.
- Invariant 3 — No cross-notification coupling. A storage failure creating a notification for subscriber A does not affect the notification record created for subscriber B. Each
Notification.createcall is independent; its success or failure is isolated to that record. - Invariant 4 — At-most-one notification per subscriber per fanout.
Subscription.subscribers_forreturns at most one entry persubscriber_reffor a given scope (Subscription Invariant 6 — at most one Active subscription per (subscriber_ref,event_scope) pair). The composition callsNotification.createat most once per returnedsubscriber_refper invocation. A single fanout produces at most one notification per subscriber. - Invariant 5 — Subscription store is read-only. The composition never writes to the subscription store.
Subscription.subscribers_foris the only call made against the Subscription atom. No subscription is created, modified, or cancelled by the fanout action. - Invariant 6 — Notification atom invariants preserved. All nine Notification invariants hold over each created record. The composition does not bypass Notification’s preconditions or write to the notification store directly.
- Invariant 7 — Subscription atom invariants preserved. All nine Subscription invariants hold. The composition reads the subscription store through the declared Q surface; it does not join the subscription table directly.
- Invariant 8 — Fanout invocation uniqueness. Each Fanout invocation that returns a result (not
rejected) is assigned a unique Fanout Id. No two invocations share a Fanout Id across the lifetime of the system. Fanout Id is generated before any constituent calls; it is present in every non-rejected result, including the empty-subscriber case. When Event Log is composed in, Fanout Id is the durable invocation identity. Without Event Log, Fanout Id is ephemeral — the caller receives it and may use it for transient correlation, but the composition does not persist it.
Fanout coverage (Invariant 1) and at-most-one-per-subscriber (Invariant 4) together give the delivery scope completeness property — every currently-Active subscriber receives exactly one notification record per invocation, or the failure is named. Payload consistency (Invariant 2) and no cross-notification coupling (Invariant 3) give the independent delivery record property — each recipient’s record is self-contained and its lifecycle is not affected by any other recipient’s outcome.
Examples
Walkthrough
A project management system uses Notification Fanout to notify subscribers when a task is assigned.
- Three team members subscribe.
Subscription.subscribe(dev_a, "task:assigned") → sub_a1. Same for dev_b and dev_c. - A task is assigned; the fanout fires.
fanout("task:assigned", {task_id: t7, assigned_by: manager_m}):fanout_id = fanout_f01generated.Subscription.subscribers_for("task:assigned") → [dev_a, dev_b, dev_c]Notification.create(dev_a, payload) → notif_41Notification.create(dev_b, payload) → notif_42Notification.create(dev_c, payload) → notif_43- Returns
{fanout_id: fanout_f01, created: [notif_41, notif_42, notif_43], failed: []}.
- dev_b cancels before the next event.
Subscription.cancel(sub_b1) → ok. - A second task is assigned.
fanout("task:assigned", {task_id: t8, assigned_by: manager_m}):fanout_id = fanout_f02generated.subscribers_for → [dev_a, dev_c]— dev_b is now Cancelled; not returned.- Returns
{fanout_id: fanout_f02, created: [notif_51, notif_52], failed: []}.
- dev_b’s earlier notifications are unaffected.
Notification.status_of(notif_42)returns the full record; the subscription cancellation does not delete prior notification records (Notification Invariant 9).
Invalid input
A caller passes a null payload.
fanout("task:assigned", null)→ step 1: payload is null; validation fails immediately before any id is generated or any constituent is called.- Returns
rejected(invalid-request). No Fanout Id is generated; no subscriber query is made; no notification records are created.
The same rejection fires for an empty event_scope: fanout("", {task_id: t9}) → rejected(invalid-request).
Subscription store unavailable
The subscription store is down when the fanout fires.
fanout("task:assigned", {task_id: t9, assigned_by: manager_m})→ step 3:Subscription.subscribers_forfails with an infrastructure error.- Returns
rejected(subscribers-unavailable). No notification records are created; the Fanout Id generated in step 2 is discarded and not returned — the invocation did not complete. The caller may retry when the store recovers.
Partial failure
During a fanout, the notification store becomes temporarily unavailable after the first create succeeds.
fanout_id = fanout_f03generated.subscribers_for → [dev_a, dev_b, dev_c]Notification.create(dev_a, payload) → notif_61✓Notification.create(dev_b, payload) → rejected(storage-failure)✗Notification.create(dev_c, payload) → notif_62✓ (fan-out continues)- Returns
{fanout_id: fanout_f03, created: [notif_61, notif_62], failed: [dev_b]}.
The caller inspects the Failed list and calls Notification.create(dev_b, payload) → notif_63 directly. That new record enters Pending independently; dev_a’s and dev_c’s records are already in their own delivery lifecycles.
Compliance system — policy change broadcast
An administrator publishes a revised data-handling policy. Every compliance officer with an Active subscription to policy:updated events must receive a notification. fanout("policy:updated", {policy_id: p12, effective_date: "2025-09-01"}) fires. Three officers are Active; three Notification records are created. Each officer’s delivery outcome is tracked independently: officer_a delivered, officer_b failed (email bounce), officer_c expired (no delivery attempt within the window).
An auditor later asks: was every subscribed compliance officer notified of policy p12? The auditor queries the notification store for records where payload.policy_id = p12. Three records appear — one per officer — with their respective delivery outcomes. The Subscription store shows each officer held an Active subscription for the policy:updated scope. Invariant 1 gives the structural answer: the Created set accounts for all subscribers returned by the fanout query. For a precise binding to the exact fanout invocation — confirming no Active subscriber at that specific moment was omitted — a composed Event Log recording the fanout with fired_at provides the timestamp needed to apply Subscription’s historical-state filter; without it, Active-status confirmation is over the general period rather than the exact fanout moment.
Regulated adversarial scenarios
- Regulator audit — demonstrate all subscribers were notified of a compliance event. An auditor asks: show all notification records created by the policy:updated fanout on 2025-08-15 and whether each was delivered. The auditor queries the notification store for records where
created_atfalls on 2025-08-15 and the payload references the relevant policy. For each returned record,status_ofshows the delivery outcome. Invariants 1 and 4 are the structural guarantees. Note on completeness: the Subscription store does support historical reconstruction of who was Active at any given moment — Subscription Invariant 9 (timestamp ordering) plus the immutablesubscribed_at/cancelled_atfields make the filtersubscribed_at ≤ TAND (status = activeORcancelled_at > T) exact to within Invariant 9’s best-effort clock caveat. The actual completeness gap is different: the auditor needs to know the exact fanout time — the moment of thesubscribers_forquery — to apply the filter. The Subscription store doesn’t record fanout invocations; that timestamp lives in Event Log, not Subscription. A composed Event Log recording the fanout invocation with itsfired_attimestamp (see Generation acceptance check 1) is therefore required to bind the audit to a specific fanout invocation among potentially many for the same scope. Without it, the auditor can identify who was notified from the notification records, but cannot pin the audit to one specific fanout. - Disputed notification — subscriber claims they were never notified. An officer claims no notification of policy p12 arrived. The investigator queries the notification store for records where
recipient_ref = officer_refandpayload.policy_id = p12. If a record exists in any state, the store confirms the delivery attempt and its outcome. If the record showsfailed_atorexpired_at, the store confirms delivery did not succeed; the Failed list from the fanout result (logged via Event Log if composed) identifies this as a named failure, not a silent omission. If no record exists, either the officer had no Active subscription at fanout time (query the subscription store) or their create call returnedstorage-failure— again, a named failure, not a gap. The subscription and notification stores together answer the question. - Breach investigation — identify all notifications that may have carried sensitive payload data. A security incident requires identifying every notification created by fanouts referencing policy p12. The investigator queries the notification store for records where
payload.policy_id = p12and applies the historical-status reconstruction logic from Notification’s regulated adversarial scenarios (created_at ≤ breach_timeand status was Pending during the window). The notification store answers the exposure scope from stored fields alone.
Edge cases and explicit non-goals
- Fanout idempotency and crash-mid-execution. The bare composition provides no idempotency guarantee. Two distinct failure modes require attention. First: if Fanout is called twice for the same event (network retry, double-click, replay), two full rounds of
Notification.createexecute — two notification records per subscriber. Second, and more dangerous: if the composition crashes mid-execution after some creates have succeeded, the{created, failed}result is never returned. The caller has no record of which subscribers received a notification record; a retry without idempotency creates duplicates for subscribers whose creates already succeeded. In both cases, composing Duplicate Prevention to guard the Fanout call provides at-most-once fanout semantics within the deduplication window. Without it, the caller must treat any retry as a potential duplicate-creation event and handle the resulting multiple notification records at the delivery layer. - Subscriber-set staleness between query and create.
Subscription.subscribers_foris called once at the start of the fanout. A subscriber who cancels after the query but before theirNotification.createis called will still receive a notification record — their subscription was Active at query time. Whether the delivery should proceed is a deployment policy the composing system defines, not a correctness failure of the composition. - New subscribers after query. A subscriber who becomes Active after
subscribers_forexecutes does not receive a notification for that fanout invocation. They will receive notifications from subsequent fanouts. This is correct: the composition delivers to the Active set at trigger time. - Empty Active subscriber set. Fanout returns
{fanout_id, created: [], failed: []}. No Notification records are created. This is a valid, non-error outcome. The Fanout Id is still generated and returned — it is the invocation’s correlation handle regardless of the subscriber count. The composing system may log this via Event Log if observability of empty fanouts is required. - Event scope hierarchy and wildcards.
Subscription.subscribers_forperforms exact-match on the event scope. A subscriber with scopetask:*does not receive notifications fortask:assignedunder the bare atoms. Scope hierarchy and pattern matching belong to a composing pattern that expands scope expressions before callingsubscribers_for. - Delivery ordering. Notification records are created in an unspecified order. The Notification atom does not guarantee delivery in creation order. If ordered delivery is required, the composing delivery layer sorts
Notification.pending_forresults bycreated_at. -
Caller disposition on the Failed list: transient failures vs. structural inconsistencies. The composition returns
{failed}rather than aborting on firstNotification.createfailure by design — the mechanism cannot know whether a missed delivery matters; only the caller can. An all-or-nothing design would guarantee no subscriber receives a notification when the store is briefly unavailable, regardless of the event’s stakes. The current design guarantees delivery to every reachable subscriber and surfaces the unreachable set for policy-level disposition. Delivery to the reachable majority is almost always worth more than guaranteed consistency with the unreachable minority; the Failed list is the pressure valve that makes the tradeoff explicit rather than silent.Two distinct failure conditions collapse into Failed, and they carry different caller obligations. Transient failures —
Notification.createreturnedrejected(storage-failure)— are retry-eligible: thesubscriber_refis valid, the payload passed validation, the notification store was temporarily unavailable. CallingNotification.create(subscriber_ref, payload)directly against the Failed list will likely succeed when the store recovers. Structural inconsistencies —Notification.createreturnedrejected(invalid-request)despite thesubscriber_refbeing non-empty and the payload passing fanout’s own validation — indicate a contract mismatch: Subscription’s definition of a validsubscriber_refdoes not match Notification’s. A retry will fail with the same rejection. Persistent failure for a specificsubscriber_refacross multiple retries is the diagnostic signal; the first failure is ambiguous.The composition collapses both into Failed because it cannot classify the inconsistency without retrying and observing persistence — the caller, who knows the domain semantics of
subscriber_ref, is better positioned to do that. Callers needing reason-level diagnostics at the first failure compose Event Log to capture eachNotification.createoutcome at the call site.Caller policy follows from the event’s stakes. For low-stakes events — activity feeds, engagement notifications — inspecting the Failed count, logging it, and accepting the loss is the appropriate disposition: the fanout reached all structurally valid subscribers, and the gap is named, not hidden. For high-stakes events — regulated notifications such as policy updates, account actions, and legal notices — the Failed list is a delivery obligation: retry transient failures until the store recovers, and for persistent structural failures escalate to a secondary delivery channel (physical mail, phone, manual outreach) or record the gap in Audit Trail as a named delivery failure with attribution and timestamp. In both cases the composition’s behavior is identical; only the caller’s policy differs. This is the boundary the composition enforces: mechanism here, policy in the composing system.
- Retry targeting the original failed set. A caller who retries Fanout re-queries
subscribers_for, which may return a different set than the original invocation. Callers who need to retry exactly the failedsubscriber_refsshould callNotification.createdirectly for each ref in the Failed list rather than re-invoking Fanout. - Transport mechanism. This composition creates Notification records; it does not dispatch them to recipients. The delivery layer — WebSocket push, webhook POST, email send — reads
Notification.pending_forand callsdeliver,fail, orexpire. Transport is handled at the deployment layer, outside this composition. - Authorization to fanout. The composition does not enforce who may call Fanout. Any caller may trigger a fanout for any event scope with any payload. Authorization belongs to the composing system — typically Permissions gating the Fanout action against the caller, optionally with Actor Identity attesting who triggered the invocation when attribution is required for audit.
- Payload size and content. Payload is opaque and passed to
Notification.createunchanged. Size limits, schema validation, and content restrictions belong to the composing system before calling Fanout. - Fan-out at scale. N sequential or parallel
createcalls scale with the Active subscriber count. For scopes with thousands of Active subscribers, the implementation must handle throughput (batching, cursor-pagination ofsubscribers_for, parallel creates). The spec does not constrain the execution strategy as long as Invariant 1 (fanout coverage) holds.
Terms
The canonical concepts this spec refers to. Each [Term] marker in the prose above links to its card here. A card states what the concept is, in plain English, plus its Kind — one of four: Type (a thing or category), Operation (a behavior), Member (a value of an enumerated Type), or, for a named datum, Field (a datum a Type carries — what does it carry?) or Parameter (a value an Operation needs — what does it need?). A card also names the Type it is a Member of / Field of, the Operation it is a Parameter of, and its Role where the domain assigns one. A card carries one Projects line — the concept’s single canonical lowering token, the one place the concrete name stays visible on the page — for every Field, Parameter, and pinned/wire Member. Everything else about casing (each target’s snake / camel / pascal / const / wire form) is derived from that one token by tools/harness/term-adapter.mjs, never hand-written. This is a composition, so its own concepts are the single emergent action it exposes (Fanout) and the parts of the result that action returns — the Fanout Id correlation handle it generates, plus the Created and Failed lists that partition the subscriber set — and its own Subscribers Unavailable rejection. The composition keeps no state of its own (Composition state: none), so there is no record store to card. References to the constituent atoms and their operations — Subscription’s subscribers_for, Notification’s create / status_of — the relayed constituent tokens (event_scope, subscriber_ref, notification_id, payload), and the inherited rejections (invalid-request, storage-failure) remain qualified/backticked, not carded here. (annotation.md Terms registry; representational only — it changes no guarantee, invariant, or behavior of the composition above.)
Fanout
The composition’s single emergent action: given an event_scope and a payload, it queries Subscription.subscribers_for once and calls Notification.create once per returned subscriber, continuing through per-recipient failures rather than aborting. Returns the Fanout Id with the Created and Failed lists, or rejects Subscribers Unavailable (the store read failed) or invalid-request (bad input). Neither constituent atom carries this fan-out action.
Kind: Operation
Fanout Id
The opaque, invocation-unique correlation handle the composition generates for each fanout (Invariant 8). Present in every non-rejected result, including the empty-subscriber case; ephemeral unless Event Log is composed in, in which case it becomes the durable invocation identity. Not returned on rejection.
Kind: Field Field of: the fanout result Role: the invocation correlation handle Projects: fanout_id
Created
The result list of notification_ids for the subscribers whose Notification.create succeeded in this fanout.
Kind: Field Field of: the fanout result Role: the succeeded recipients Projects: created
Failed
The result list of subscriber_refs whose Notification.create did not record — every rejection reason collapsed into one “delivery did not record” outcome. Together with Created it accounts for every subscriber in the query result (Invariant 1: |created| + |failed| = |subscribers|); it is the pressure valve that makes the reachable/unreachable split explicit rather than silent.
Kind: Field Field of: the fanout result Role: the unreached recipients (retry-eligible) Projects: failed
Subscribers Unavailable
The composition’s own rejection from Fanout — returned when the subscription-store read (Subscription.subscribers_for) fails with an infrastructure error. No notification records are created and no Fanout Id is returned; the invocation did not complete.
Kind: Member Member of: the fanout rejection Role: Rejection Projects: subscribers-unavailable
Generation acceptance
A derived implementation of Notification Fanout is acceptable when an external auditor, given the subscription store and notification store, can do all of the following without recourse to source code, runbooks, or developer narration.
Record-clearable checks
These checks can be answered by reading the composition’s stored records (subscription store, notification store, and Event Log where composed in):
- Confirm fanout coverage for any recorded fanout. Event Log composition is required for reliable fanout-coverage audits. The recommended Event Log entry shape — one entry per Fanout invocation — is
{fanout_id, event_scope, payload_digest, created: [notification_id, ...], failed: [subscriber_ref, ...], fired_at}. Fanout Id is the durable invocation identity when Event Log is composed in; the caller passes the Fanout Id returned by the fanout action as the log entry’s reference field, binding the invocation record to its complete subscriber list and creatednotification_ids. Given an Event Log entry of this shape, the auditor can: (a) read the entry’sevent_scopeandfired_at; (b) reconstruct the Active subscriber set atfired_atusing Subscription’s historical-state filter (subscribed_at ≤ fired_atAND (status = activeORcancelled_at > fired_at)); (c) verify every reconstructed Active subscriber appears either in Created (eachnotification_idmapped viaNotification.status_ofto confirm the record exists with matchingrecipient_ref) or in Failed; (d) confirm|created| + |failed|equals the size of the reconstructed Active set, satisfying Invariant 1 from records. Without a composed Event Log carrying these fields, fanout grouping bycreated_atclustering on the notification store is unreliable — concurrent creates across a measurable time span produce different timestamps, and concurrent unrelated fanouts on the same scope produce overlapping ones; Fanout Id alone is insufficient without the log because the composition does not persist it. - Confirm payload consistency. All Notification records produced by a single fanout carry the same payload. Identifying the fanout group requires the same Event Log entry as check 1 — the
created: [notification_id, ...]list keyed by Fanout Id is the authoritative grouping; without it, grouping by payload similarity is ambiguous when multiple concurrent fanouts share the same payload structure. Given the group, the auditor inspects thepayloadfield of each record and confirms identity across all members. - Verify each Notification record independently. Each record passes Notification’s five Generation acceptance checks: full delivery history present, timeline reconstructable, terminal exclusivity confirmed, timestamp-status match confirmed, composing patterns identifiable.
- Confirm no cross-notification coupling. A terminal state on one notification record in the fanout group does not correlate with the terminal state on another. Each record’s delivery outcome is independent.
Externally-clearable checks
These questions arise around the composition but require deployment configuration or external evidence to answer:
- Identify the composing patterns active in this deployment — whether Event Log, Duplicate Prevention, Actor Identity, and Tamper Evidence are wired alongside the bare fanout mechanism, and with what configuration. The presence and configuration of these composing patterns is a deployment-level fact; the auditor must obtain this from the deployment configuration record or the operator, not from the subscription or notification stores alone.
Standards references
- Observer pattern (GoF) — Notification Fanout is the Subject’s
notify()method: iterate the observer list, deliver the update to each. The atoms formalize what the pattern assumes. - Publish-subscribe (Birman & Joseph, 1987; AMQP topic exchanges; Apache Kafka consumer groups) — the event-scope-to-subscriber binding is a topic subscription; Fanout is message dispatch.
- WebSub (W3C Recommendation) — hub-based publish-subscribe; Fanout corresponds to the hub’s distribution step after a publisher notifies the hub of a content update.
- W3C Activity Streams 2.0 — notification payloads in web deployments often carry Activity Streams objects; the fanout composition is payload-agnostic.
- Outbox pattern (Chris Richardson, Microservices Patterns) — for reliable fanout, the notification records produced by Fanout are the outbox entries the delivery layer consumes. The composition produces the records; the delivery layer is out of scope.
It inherits from:
- Subscription — standards inheritance in full: Observer pattern, pub-sub, WebSub.
- Notification — standards inheritance in full: Observer pattern, SMTP, HTTP webhooks, W3C Activity Streams, APNs/FCM.
Status
grounded on Final Critique 4 — 2026-05-20 — three foundation passes complete; Opus adversarial pass (26 findings, all resolved); architectural decisions applied (Event Log optional, fanout_id ephemeral correlation handle with Event Log as durable identity when composed, subscribers-unavailable treated as explicit error). Under the unified methodology (3×3 baseline rounds with per-round Pass 1/2/3 numbering + Final Critique starting at Round 4), this pattern’s Opus adversarial pass is retro-labeled Final Critique 4; the original round-naming in the Lineage notes below is preserved as historical record.
Lineage notes
Drafted as the fifth entry in compositions/, following Undo History, Idempotent Reservation, Audit Trail, and Shared Todo. First composition in the library with variable fan-out semantics — N effects from one trigger, where N is determined at runtime by the Active subscriber count.
The fan-out decomposition model was formalized in execution-contract.md before drafting: fan-out is a decomposition boundary, not a single transition; the composition is a stateless interpreter of a directed invocation graph; no rollback guarantee exists across independent creates; partial failure is per-recipient and named in the result, not an abort condition. The draft follows this model directly.
Conventions inherited from prior work. Regulated adversarial scenarios and Generation acceptance are included because the compliance system example invokes a regulated domain (policy broadcast with mandated delivery to compliance officers), per the conventions in pressure-testing.md. Inherited from the methodology directly; not re-derived from Notification or Subscription.
Pass 1 — Structural completeness (GRID — the nine-node completeness framework: Intent, System, Friction, Flow, Decision, Feedback, State, Behavior, Proof). Clean. All nine GRID nodes resolved. Intent is testable and falsifiable via Invariant 1. Decision points cover all branching paths: subscribers-unavailable (abort before any creates), empty subscriber list (return empty result), storage-failure on a create (continue to next subscriber), invalid-request on a create (structural inconsistency — treated as storage-failure). Proof is measurable: generation acceptance names five checks an external auditor can run against the subscriber and notification stores. Composition state is explicitly none; the rationale (Event Log and Duplicate Prevention as optional composing patterns) is named.
Pass 2 — Conceptual independence (EOS — the Essence of Software, Daniel Jackson’s concept framework). Clean. No concerns are absorbed that belong elsewhere. Idempotency (Duplicate Prevention), audit history (Event Log), delivery transport (deployment concern), scope hierarchy (composing pattern), and authorization (composing system) are all correctly named as out-of-scope. The {created, failed} return structure is a return value from a single action invocation, not persistent state — no hidden store exists or is implied.
Pass 3 — Adversarial scrutiny (Linus mode), first run. Clean in-pattern; one cross-file finding deferred.
In-pattern resolutions: Parallel execution constraint named explicitly — no shared transaction boundary across the N creates. Retry targeting distinction made explicit. Subscriber-set staleness committed to trigger-time semantics. The invalid-request edge case (structurally inconsistent) named and handled consistently with storage-failure.
Cross-file finding — deferred: Subscription.subscribers_for does not name a store-unavailable outcome in its Decision points. This composition correctly handles it as rejected(subscribers-unavailable). The gap belongs to the Subscription atom’s next refinement round.
Pass 3, second run — five findings, all closed in-pattern.
- Missing input preconditions on Fanout (Pass 3). The action wiring had no precondition check on
event_scopeorpayload. A null payload would silently route every subscriber to Failed via the constituent’sinvalid-requestpath rather than failing fast. An emptyevent_scopewould return{created: [], failed: []}— masking a likely caller error. Resolved:invalid-requestadded to the signature; step 1 added to the action wiring with explicit precondition checks matching the constituent atoms’ validation patterns. - Missing rejection-path example (Pass 1/Pass 3). No example showed the
rejected(subscribers-unavailable)path. Resolved: “Subscription store unavailable” example added showing the clean abort case. - Crash-mid-execution not named (Pass 3). The fanout idempotency edge case covered double-call but not crash-after-partial-creates. The crash scenario is more dangerous — no
{created, failed}result returned, caller has no visibility into which creates succeeded, retry creates duplicates. Resolved: fanout idempotency edge case extended to name both failure modes and the Duplicate Prevention mitigation for each. - Generation acceptance check 1 unreliable without Event Log (Pass 3). “
created_atclustering” is not a reliable fanout-grouping mechanism. Resolved: check 1 rewritten to make Event Log composition a stated requirement for reliable fanout-coverage audits; unreliability of clustering noted explicitly. - Regulated adversarial scenario 1 assumes historical subscription state (Pass 3). “Cross-referencing against the Subscription store confirms no Active subscriber was omitted” was false — the Subscription store shows current status, not status at fanout time. A subscriber Active at fanout time who has since cancelled is invisible as such. Resolved: scenario 1 updated to acknowledge this limitation and name Event Log composition as the structural solution for historical completeness.
Refinement round — adversarial rerun. Six findings — one critical stale cross-reference and five Pass 3 surfaces — all closed in-pattern.
- Stale invariant count (Pass 1, critical). Invariant 7 read “All eight Subscription invariants hold.” Subscription has nine. This is exactly the failure mode
pressure-testing.mdwarned about (“Shared Todo, referencing ‘nine Assignment invariants’ after Assignment gained a tenth during its own refinement round”) — encountered the second time in the library. Resolved: corrected to “All nine Subscription invariants hold.” The structural risk (any invariant-count cross-reference goes stale the moment its constituent gains an invariant) remains and is flagged below as a library-wide concern. - Failed list elides rejection reason (Pass 3). Step 3 collapses
storage-failureandinvalid-requestinto the Failed list with no reason distinction, but Retry semantics tells callers to retry by callingNotification.createdirectly — which will keep failing for the structurally-inconsistent case. Resolved: Retry semantics now states explicitly that all Failed entries are retry-eligible regardless of underlying reason; persistent failure across retries indicates structural inconsistency the composing system resolves out-of-band; callers needing reason-level diagnostics compose Event Log at the call site. - Generation acceptance check 1 depended on an undefined Event Log entry shape (Pass 3). The check said “via a composed Event Log — which records the invocation, the event scope, and the resulting
notification_idset” without naming the entry shape. Event Log is a generic append-only log; the composing system decides what to record. Resolved: check 1 now names the recommended Event Log entry shape inline ({event_type: "fanout", event_scope, fanout_time, created_notification_ids, failed_subscriber_refs}) and reframes the check as a four-step procedure built around that shape’s queryability. - First regulated adversarial scenario’s reasoning was wrong; conclusion was right (Pass 3). The scenario claimed the Subscription store “shows each subscriber’s present status, not their status at fanout time” — but Subscription’s own Generation acceptance check 2 demonstrates that historical Active-set reconstruction is supported from the Subscription store alone. The actual completeness gap is identifying the fanout time, which lives in Event Log. Conclusion (Event Log required) stands; reasoning was rewritten to bind the audit to a specific fanout invocation rather than misrepresenting Subscription’s query power.
- Authorization-to-fanout had no named composing pattern (Pass 3). Edge cases named authorization as out-of-scope without linking any atom. Resolved: Permissions named as the typical authorization composing pattern, with Actor Identity for attribution when required.
- Forthcoming-link cleanup propagated. Subscription and Notification both still carried
*(forthcoming)*markers on their Notification Fanout references after this composition had landed. Both markers removed; references linked. (Cross-file fix recorded in those atoms’ refinement-round lineage entries as well.)
Library-wide concerns surfaced but not resolved in this round — recorded here for the next sweep:
- Invariant-count cross-references remain structurally fragile. The mechanical fix (drop counts, say “all Subscription invariants” without a number) loses precision but eliminates staleness risk. pressure-testing.md already names this hazard; whether to do the library-wide rewrite is an authorial call. Encountered twice now (Shared Todo’s nine-vs-ten on Assignment, this composition’s eight-vs-nine on Subscription); the third encounter probably forces the convention.
- Closed-action vs. open-audit tension. The two constituent atoms’ refinement rounds resolved this per-pattern by adding an explicit audit-surface preamble to each Generation acceptance section. A canonical statement of the distinction belongs in
pressure-testing.mdorcontributing.mdso future patterns inherit the convention rather than re-derive it.
Final clean re-run — six findings, all closed in-pattern.
- Step 5 field name inconsistency (Pass 1). Step 5 returned
{fanout_id, created: [notification_ids], failed: [subscriber_refs]}— pluralized field names inconsistent with the signature’s[notification_id, ...]and[subscriber_ref, ...]notation. Resolved: normalized to{fanout_id, created: [notification_id, ...], failed: [subscriber_ref, ...]}. - Edge case “Empty Active subscriber set” missing fanout_id (Pass 1). The edge case showed
{created: [], failed: []}— stale from before fanout_id was added. Resolved: corrected to{fanout_id, created: [], failed: []}with explicit note that fanout_id is generated and returned regardless of subscriber count. - No example for
rejected(invalid-request)(Pass 3). Both rejection reasons were named in the signature but onlysubscribers-unavailablehad a dedicated example. Resolved: “Invalid input” example added showing both the null-payload and empty-event_scope paths. The “Subscription store unavailable” example also corrected its step reference from “step 2” to “step 3” following the renumbering from the fanout_id decision round. - Compliance system example overconfident about Subscription store (Pass 3). “The Subscription store confirms all three were Active at notification time” was the same time-binding error corrected in Regulated adversarial scenario 1 — knowing “at notification time” requires the fanout time, which lives in Event Log, not the Subscription store. Resolved: weakened to “The Subscription store shows each officer held an Active subscription for the scope” with an explicit note that a composed Event Log provides the precise time-binding.
- Generation acceptance check 2 assumed fanout group was pre-identified (Pass 3). The check referred to “the fanout group” without noting that group membership requires the same Event Log entry as check 1. A reader of check 2 in isolation would not know that fanout grouping requires Event Log. Resolved: check 2 now names the Event Log dependency explicitly before describing the payload-comparison procedure.
fanout_timevsfired_atinconsistency (Pass 3). Regulated adversarial scenario 1 referenced the fanout timestamp asfanout_time— the field name used in the intermediate Event Log entry shape introduced in the adversarial rerun. Generation acceptance check 1 usesfired_atas the canonical field name in the final entry shape. Resolved: scenario 1 updated to usefired_atconsistently with the canonical entry shape.
Post-grounding addition — Failed list policy/mechanism split. The {failed} list design was never defended in-line: the spec named the return value and told the caller to inspect it, but never stated why the mechanism returns {failed} rather than aborting — a load-bearing architectural decision left implicit. Resolved: new edge case entry “Caller disposition on the Failed list: transient failures vs. structural inconsistencies” added, which (a) defends the abort-vs-continue choice (policy/mechanism separation; delivery to the reachable majority is worth more than consistency with the unreachable minority), (b) distinguishes transient storage-failure entries (retry-eligible) from structural invalid-request entries (retry will fail; persistent failure is the diagnostic), (c) names two concrete caller dispositions — accept-the-loss for low-stakes events, escalate-to-secondary-delivery or log-to-Audit-Trail for regulated events — and explicitly names Audit Trail as the composition for recording a delivery gap with attribution and timestamp in the high-stakes case.
Scheduled rescan: 2026-05-20. Pass 1 GRID — three refining structural gaps, all closed in-pattern. Constituent API spot-check confirmed: Subscription retains nine invariants; Notification retains nine invariants; both invariant counts in the composition’s Invariants section are accurate. Pass 2 EOS clean. Pass 3 Linus (fresh-reader) clean.
- No Primitive policies subsection (refining).
event_scopeandpayloadvalidation rules were stated inline in Action wiring step 1 but not collected in a Primitive policies subsection. SPEC_FORMAT requires the subsection for composition-boundary input validation. Resolved: Primitive policies subsection added namingevent_scope(non-null, non-empty, opaque, exact-match, validated before any constituent call) andpayload(non-null, opaque, content restrictions delegated to composing system). - No “load-bearing wiring decision” subsection (refining). The key architectural decision — continue-on-failure rather than abort-on-first-failure — was present in prose (Action wiring, Edge cases “Caller disposition”) but not stated and defended as a canonical named subsection per SPEC_FORMAT. Resolved: “The load-bearing wiring decision” subsection added to Composition logic, defending the continue-and-name-failures design with the four-part rubric.
- Generation acceptance not split into record-clearable / externally-clearable (refining). SPEC_FORMAT requires compositions to split Generation acceptance checks. The five prior checks were record-clearable except check 5 (“Identify composing patterns active”), which requires deployment configuration. Resolved: Generation acceptance restructured into “Record-clearable checks” (four checks) and “Externally-clearable checks” (one check — composing-pattern configuration identification). Round closes clean.
Formal-layer vote — 2026-06-03: NO. Emergent invariants (fanout coverage, payload consistency, at-most-one per subscriber) are single-invocation structural coverage properties — no ordering, concurrency, or gate-before-action claim. Grounds English-only (minimum-formalism). Vote per pressure-testing.md §Formal models — The formal-layer vote.
Showcase pass — 2026-06-29. Representational-only annotation/legibility pass; no guarantee, invariant, number, formula, signature, or rejection taxonomy changed (the invariant count held at eight). (a) Four-kind [Term] annotation applied across the body and a ## Terms registry added after Edge cases (5 terms): 1 Operation — the single emergent Fanout action; 3 Fields — the parts of the fanout result (Fanout Id correlation handle, Created and Failed lists); and 1 Member — the composition’s own Subscribers Unavailable rejection. No record store carded: the composition keeps no state of its own (Composition state: none). Because this is an earlier, loosely-tokenized draft, the pass also backticked the relayed constituent wire tokens left bare in the body (subscriber_ref ×12, event_scope, subscriber_refs, notification_ids) as survivors — a representational tidy that touched no wording (concrete example ids like dev_a / officer_a were left as-is). Survivors left backticked: the labeled projected-contract signature; the qualified constituent calls (Subscription.subscribers_for, Notification.create / status_of / pending_for) and outcomes (ok); the relayed constituent tokens (event_scope, subscriber_ref, notification_id, payload); the inherited rejections (invalid-request, storage-failure); and concrete example ids and payloads. Constituent atom names remain the existing full links to ../atoms/*; constituent operations stay backticked qualified calls, not cross-page links (the decided convention). (b) Summary/blockquote merge — ## Summary moved to the top (after TOC, before Intent), its first run-on paragraph split one-idea-per-paragraph (cut #1, lossless) with the “common uses” paragraph kept intact; the descriptive top blockquote folded out after confirming each claim (active-subscriber fan-out, the two-atom composition, the query-through-per-recipient-record pipeline) is carried by Summary / Intent / Composes; no also-known-as line existed, so none was invented. (c) Lineage collapsed into a <details markdown="block"> block. (d) prose cut #5 — skipped (with reason): the composition owns no state machine — it is a stateless interpreter of a directed invocation graph (Composition state: none), so there is no lifecycle prose to condense into a table. Re-verified, not re-grounded: Status stays at grounded on Final Critique 4 — 2026-05-20. Formal-layer vote is NO (English-only, no model), so the harness is N/A for this composition. Gates: lint clean (O-term resolver — every marker resolves and every card is used); term-adapter derives cleanly (5 terms); eight composition-level invariants preserved; casing scan clean (only frontmatter and concrete example ids remain bare).