Notification Fanout

Composition grounded on Final Critique 11 — 2026-11-05 at 63c13eb 6 invariants regulated composes 2 atoms
Derived card — generated from the corpus (status line, invariant headers, sibling formal-model files, Composes edges). The specification below is canonical. composition graph →
Table of contents
  1. Notification Fanout
    1. Summary
    2. Intent
    3. Composes
    4. Composition logic
      1. Composition state
      2. Capability requirement
      3. Primitive policy
      4. Action wiring
        1. Closure table
      5. Wiring decision
    5. Composition-level invariants
    6. Examples
      1. Walkthrough
      2. Invalid input
      3. Subscription records unavailable
      4. Partial failure
      5. Compliance system — policy change broadcast
      6. Regulated adversarial scenarios
    7. Generation acceptance
      1. Conformance checks
      2. External checks
    8. Non-goals
    9. Edge cases
      1. Clock semantics
      2. Indeterminate outcome
    10. Composition notes
    11. Terms
      1. Vocabulary
        1. Fanout
        2. Fanout Id
        3. Created
        4. Firing Instant
        5. Failed
        6. Subscribers Unavailable
        7. Entropy Floor
        8. Read Latency Bound
        9. Boundary Window
    12. Standards references
    13. Status
    14. Ledger

Summary

Notification Fanout connects an event to everyone who wants to hear about it. It is a composition, a pattern built by combining simpler patterns, of two: 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 when the subscription records answer the query the event triggers — 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) whenever one broadcast runs to completion — a crash partway through a broadcast can leave created records with no returned result, so a caller who retries may give someone a second record — and that all records from one event carry the same content. The composition keeps no records of its own; auditing, recording who was notified and checking for repeats 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 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 failure to record one recipient’s notification 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 — the Active subscriber set, read and never written.
  • Notification — the per-recipient delivery record, created once per subscriber.
Composes 1: EXACTLY ONE Subscription instance MUST serve the composition.
Composes 2: EXACTLY ONE Notification instance MUST serve the composition.
Composes 3: The composition MUST NOT change a constituent's spec.
Composes 5: The composition MUST read the subscription records through Subscription's subscribers_for.
Composes 6: The composition MUST NOT write to the notification records beside Notification's create.
Composes 7: The composition MUST inherit a constituent's invariants PER Execution Contract Conformance 8.
Deleted: Composes 4. Invariant 5.1 owns it.

Term composition: this pattern’s wiring of Subscription and Notification — the one action below, its fan-out and its two result lists.

Term constituents: Subscription, Notification.


Composition logic

Composition state

Composition state 1: The composition MUST NOT store a record.
Composition state 2: The composition MUST NOT persist a fanout id.
Composition state 3: A deployment needing a fanout recorded MUST compose Event Log.
Composition state 3a: A deployment composing Event Log for a fanout MUST record the event scope, the fanout id, the firing instant, the created list AND the failed list of EVERY admitted fanout answering a result in EXACTLY ONE Event Log entry.
Composition state 4: A deployment needing a fanout deduplicated MUST compose Duplicate Prevention.
Composition state 5: A deployment composing Duplicate Prevention MUST supply the caller's event identity as Duplicate Prevention's identity.
Composition state 5a: A deployment composing Duplicate Prevention MUST call a fanout ONLY AFTER Duplicate Prevention's check of the caller's event identity answers not-seen AND Duplicate Prevention's record of that identity answers ok.
Composition state 5b: A deployment needing one guard across every instance of the fanout MUST compose a coordination pattern (Duplicate Prevention Non-goal 5).
Composition state 6: EVERY notification record of a fanout MUST name EXACTLY ONE subscriber reference.
Deleted: Composition state 7. Wiring decision 3 owns it.

Capability requirement

Capability requirement 1: The deployment MUST supply now at the seam.
Capability requirement 2: The host MUST supply one fanout id at the seam for EVERY admitted fanout.
Capability requirement 3: The host MUST draw a fanout id meeting the entropy floor.
Deleted: Capability requirement 4. Execution Contract Logic confinement 3 owns it.
Deleted: Capability requirement 5. Execution Contract Logic confinement 3 owns it.
Capability requirement 6: A deployment MUST disclose the deployment's read latency bound.
Capability requirement 7: The deployment MUST declare the clock offset allowance.
Capability requirement 8: A deployment MUST bound EVERY Notification create call.
Capability requirement 9: A deployment MUST disclose the create bound.
Capability requirement 10: A deployment MUST NOT retry a create call at the transport.
Capability requirement 11: A deployment MUST bound EVERY subscribers_for call by the read latency bound.

Term seam: the composition’s I/O (input and output) boundary as the section titled Logic Confinement Principle in execution-contract.md declares it; the host injects one clock reading and one fanout id here. Term now: the deployment’s clock reading at the call, as Execution Contract Logic confinement 11 declares it.

Term transition: the composition’s evaluation of one Fanout call against the two constituents’ records, as the section titled Logic Confinement Principle in execution-contract.md declares it.

Term entropy floor: 128 bits of entropy per id, or a generator whose coordination gives the same uniqueness — an Entropy Floor; this composition’s own bound on the host.

Term create bound: the deployment’s bound on one Notification create call, past which the call is an unrecordable create — a hung create otherwise hangs the fan-out.

Term read latency bound: the deployment’s disclosed bound on the interval from the firing instant to the answer of the subscribers_for call — a Read Latency Bound.

Primitive policy

Primitive policy 1: [Fanout] MUST answer invalid-request for a blank event scope.
Primitive policy 2: [Fanout] MUST answer invalid-request for an absent payload.
Primitive policy 3: The composition MUST take the fanout id ONLY AFTER the inputs clear the boundary predicate.
Primitive policy 4: [Fanout] MUST NOT call a constituent for an input the boundary predicate refuses.
Primitive policy 5: The composition MUST compare an event scope byte-exact.
Primitive policy 6: [Fanout] MUST NOT normalize an event scope.
Primitive policy 7: [Fanout] MUST NOT bound a payload's length.

Action wiring

fanout(event_scope, payload)
  answers fanout result
  refuses invalid-request | subscribers-unavailable

Term fanout result: fanout id, created, failed and firing instant — what fanout answers.

Action wiring 1: An admitted fanout MUST call Subscription's subscribers_for with the event scope.
Action wiring 2: An admitted fanout MUST take the firing instant from the seam's clock reading.
Action wiring 3: An admitted fanout MUST take the fanout id from the seam's id.
Action wiring 4: IF Subscription's subscribers_for answers state-unavailable OR answers nothing within the read latency bound THEN [Fanout] MUST answer subscribers-unavailable (Execution Contract Constituent failure 3).
NOTE: watch the constituent's failed read (Action wiring 4).
Action wiring 5: A subscribers-unavailable answer MUST NOT carry a fanout id.
Deleted: Action wiring 6. Action wiring 9 owns it.
Action wiring 7: An admitted fanout MUST call Notification's create EXACTLY ONE time per subscriber reference the subscribers_for answer carries.
Action wiring 8: IF the subscribers_for answer carries a subscriber reference THEN an admitted fanout MUST call Notification's create with that subscriber reference as the recipient reference and with the payload.
Action wiring 9: An admitted fanout MUST NOT call Notification's create for a subscriber reference the subscribers_for answer does not carry.
Action wiring 10: IF Notification's create answers a notification id THEN an admitted fanout MUST record the notification id in the created list.
Action wiring 11: IF Notification's create answers otherwise OR answers nothing within the create bound THEN an admitted fanout MUST record the subscriber reference in the failed list (Execution Contract Constituent failure 1).
Deleted: Action wiring 12. Action wiring 7 owns it.
Action wiring 13: An admitted fanout answering a result MUST answer the fanout id, the created list, the failed list AND the firing instant.
Action wiring 14: An admitted fanout MUST answer an empty created list AND an empty failed list for an empty subscribers_for answer.
Action wiring 15: An admitted fanout MUST NOT commit two creates under one transaction PER the section titled Boundary rules in `execution-contract.md`.
Action wiring 16: An admitted fanout MUST NOT order the created list.
Action wiring 17: An admitted fanout MUST NOT order the failed list.
Action wiring 18: An admitted fanout MUST NOT answer a create's reason.

Term boundary predicate: the composition’s own input check, judged before any constituent call — an event scope that is not blank and a payload that is not absent (Primitive policy 1 through 2); a null payload is absent, and an empty or a whitespace-only one is not (Notification Operation 5).

Term admitted fanout: a Fanout call whose inputs cleared the boundary predicate.

Term created list: the notification id of EVERY create the composition saw answer — a Created list.

Term failed list: the subscriber reference of EVERY create that answered otherwise or answered nothing within the create bound — a Failed list.

Term created recipients: the recipient references Notification’s records hold for the notification ids the created list carries — what an auditor reads through status_of (Check 1.3).

Term unrecordable create: a create answering neither a notification id, nor one of Notification’s declared refusals, nor the state-unavailable a failed read answers (Execution Contract Read 1) — a call that timed out, or came back unacknowledged; the outcome the composition names at the boundary. Notification’s own storage-failure is declared, and records nothing (Notification Operation 7, Notification Operation 16), as a state-unavailable records nothing (Execution Contract Read 3).

Closure table

Every constituent call Fanout makes, and for each call its answer, its refusals, a failed read and a call that never answers, each with the rule that lands it. A create’s refusal, failed read and missing answer land in the failed list (Action wiring 11, Wiring decision 1); the composition writes nothing itself (Composition state 1), so each create commits or not alone (Action wiring 15).

Action Call Answer Refusals Failed read No answer
Fanout Subscription.subscribers_for the subscriber references, answered (Action wiring 1, Action wiring 7) none declared: subscribers_for refuses nothing state-unavailable: Action wiring 4 (Execution Contract Constituent failure 3) Action wiring 4
Fanout Notification.create, once per subscriber reference the notification id, recorded in the created list (Action wiring 10) invalid-request: Action wiring 11; storage-failure: Action wiring 11 (Execution Contract Constituent failure 1) state-unavailable: Action wiring 11 Action wiring 11, Execution Contract Error model 1

Wiring decision

Wiring decision 1: The composition MUST continue a fan-out through a failed create.
Wiring decision 2: The composition MUST NOT abort a fan-out for a failed create.
Wiring decision 3: The composition MUST account for EVERY subscriber the subscribers_for answer carries in EXACTLY ONE OF the created recipients, the failed list.
WHY

Principle. A failed create stops no other create, and is named in the result rather than hidden (Wiring decision 1 through 3).

Likely objection. The alternative is all-or-nothing.

Mechanism that resolves it. All-or-nothing fails the case it exists for: a database briefly unavailable for one subscriber would deny delivery to every reachable subscriber, trading guaranteed delivery to the reachable majority for consistency with the unreachable minority.

Result. The failed list is the pressure valve — it makes the trade explicit rather than silent, and the caller applies the policy the event’s stakes deserve, which is the mechanism-versus-policy split the composition rests on.


Composition-level invariants

Invariant 1 through 5 and Invariant 8 emerge from the composition; neither constituent carries them alone.

  • Invariant 1 — Fanout coverage.
    Invariant 1.1: An admitted fanout answering a result MUST call Notification's create EXACTLY ONE time per subscriber reference the subscribers_for answer carried.
    Invariant 1.2: EVERY subscriber reference the subscribers_for answer carried MUST stand in EXACTLY ONE OF the created recipients, the failed list.
    Invariant 1.3: The composition MUST NOT call Notification's create for a subscriber reference outside the subscribers_for answer.
    
  • Invariant 2 — Payload consistency.
    Invariant 2.1: EVERY notification record of one admitted fanout MUST carry the admitted fanout's payload.
    
  • Invariant 3 — No cross-notification coupling.
    Invariant 3.1: A failed create MUST NOT change another subscriber reference's notification record.
    Invariant 3.2: EVERY notification record of one admitted fanout MUST carry the record's own status.
    Invariant 3.3: A notification record MUST NOT reference another notification record.
    
  • Invariant 4 — At most one notification per subscriber per fanout.
    Invariant 4.1: An admitted fanout MUST NOT record two notification ids for one subscriber reference.
    Invariant 4.2: The composition MUST NOT claim a record the composition did not observe.
    
  • Invariant 5 — The subscription records are read-only.
    Invariant 5.1: The composition MUST NOT write to the subscription records.
    Deleted: Invariant 6. Composes 7 owns it.
    Deleted: Invariant 7. Composes 7 owns it.
    
  • Invariant 8 — Fanout invocation uniqueness.
    Invariant 8.1: Two admitted fanouts answering a result MUST NOT share a fanout id.
    Invariant 8.2: The composition MUST call a constituent ONLY AFTER taking a fanout id.
    Invariant 8.3: An admitted fanout answering a result MUST answer a fanout id.
    

Examples

Walkthrough

A project management system uses Notification Fanout to notify subscribers when a task is assigned.

  1. Three team members subscribe. Subscription.subscribe(dev_a, "task:assigned") → sub_a1. Same for dev_b and dev_c.
  2. A task is assigned; the fanout fires. fanout("task:assigned", {task_id: t7, assigned_by: manager_m}):
    • fanout_id = fanout_f01 taken from the seam.
    • Subscription.subscribers_for("task:assigned") → [dev_a, dev_b, dev_c]
    • Notification.create(dev_a, payload) → notif_41
    • Notification.create(dev_b, payload) → notif_42
    • Notification.create(dev_c, payload) → notif_43
    • Returns {fanout_id: fanout_f01, created: [notif_41, notif_42, notif_43], failed: [], fired_at: 2026-03-14T10:02:11Z}.
  3. dev_b cancels before the next event. Subscription.cancel(sub_b1) → ok.
  4. A second task is assigned. fanout("task:assigned", {task_id: t8, assigned_by: manager_m}):
    • fanout_id = fanout_f02 taken from the seam.
    • subscribers_for → [dev_a, dev_c] — dev_b is now Cancelled; not returned.
    • Returns {fanout_id: fanout_f02, created: [notif_51, notif_52], failed: [], fired_at: 2026-03-14T10:02:12Z}.
  5. 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) → payload is null; validation fails before any fanout id is taken or any constituent is called.
  • Returns invalid-request. No Fanout Id is taken; no subscriber query is made; no notification records are created.

The same rejection fires for an empty event scope: fanout("", {task_id: t9}) → invalid-request.

Subscription records unavailable

The subscription records are down when the fanout fires.

  • fanout("task:assigned", {task_id: t9, assigned_by: manager_m}) → Subscription.subscribers_for fails with an infrastructure error.
  • Returns subscribers-unavailable. No notification records are created; the Fanout Id taken after validation is discarded and not returned — the invocation did not complete. The caller may retry when the database recovers.

Partial failure

During a fanout, the notification records become temporarily unavailable after the first create succeeds.

  • fanout_id = fanout_f03 taken from the seam.
  • subscribers_for → [dev_a, dev_b, dev_c]
  • Notification.create(dev_a, payload) → notif_61 ✓
  • Notification.create(dev_b, payload) → no answer within the create bound, the database gone mid-write: an unrecordable create, no notification_id — the boundary classifies it as dev_b’s fanout failure (Action wiring 11) ✗
  • Notification.create(dev_c, payload) → notif_62 ✓ (fan-out continues)
  • Returns {fanout_id: fanout_f03, created: [notif_61, notif_62], failed: [dev_b], fired_at: 2026-03-14T10:02:13Z}.

The caller inspects the Failed list and retries Notification.create(dev_b, payload) → notif_63 per Indeterminate outcome 3 and Execution Contract Error model 1, knowing what that buys: dev_b’s entry was indeterminate, so if the failed write had in fact committed a record before the database went away, dev_b now holds two records from one fanout and the delivery layer sees both — the at-least-once residual the composition names rather than hides. notif_63 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’s record reached Notification’s own failed state (an email bounce), which is not the fanout’s Failed list — that list names creates that did not record; officer_c expired (the delivery window lapsed without a delivery).

An auditor later asks: was every subscribed compliance officer notified of policy p12? The auditor queries the notification records for records where payload.policy_id = p12. Three records appear — one per officer — with their respective delivery outcomes. The Subscription records show each officer held an Active subscription for the policy:updated scope. Invariant 1 gives the structural answer: the Created and Failed lists together account 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 firing instant 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 records for records where created_at falls on 2025-08-15 and the payload references the relevant policy. For each returned record, status_of shows the delivery outcome. Invariants 1 and 4 are the structural guarantees. Note on completeness: the Subscription records do support historical reconstruction of who was Active at any given moment — Subscription Invariant 9 (timestamp ordering) plus the immutable subscribed_at / cancelled_at fields make the filter subscribed_at ≤ T AND (status = active OR cancelled_at > T) exact to within the clock offset allowance. The actual completeness gap is different: the auditor needs to know the exact fanout time — the moment of the subscribers_for query — to apply the filter, and the fanout’s created list and failed list, which the Event Log entry carries (Composition state 3a). The Subscription records don’t carry fanout invocations; that timestamp lives in Event Log, not Subscription. A composed Event Log recording the fanout invocation with its firing instant timestamp (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 records for records where recipient_ref = officer_ref and payload.policy_id = p12. If a record exists in any state, the database confirms the delivery attempt and its outcome. If the record shows failed_at or expired_at, the database 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 records) or their create failed — one of Notification’s own refusals or an unrecordable create, each a named failure in the Failed list, not a gap — or the fan-out died before reaching their create, which leaves no result and no record, and is a gap only a composed Event Log can show (Composition state 3a). The subscription and notification records 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 records for records where payload.policy_id = p12 and applies the historical-status reconstruction logic from Notification’s regulated adversarial scenarios (created_at ≤ breach_time and status was Pending during the window). The notification records answer the exposure scope provably, from stored fields.

Generation acceptance

A derived implementation is acceptable when the checks below are provable to an external auditor given the subscription records and the notification records. Check 1, Check 2 and Check 4.1 are clearable only where Event Log is composed in and records the fanout PER Composition state 3a: the composition persists no fanout id, so nothing in the bare records groups a fanout.

Conformance checks

Check 1.1: An auditor MUST read a fanout's event scope and firing instant from the composed Event Log entry (Clock semantics 4).
Check 1.2: An auditor MUST reconstruct the active subscriber set at the firing instant from the subscription records' subscribed instants AND cancelled instants (Invariant 1.2).
Check 1.3: An auditor MUST find EVERY reconstructed subscriber reference in EXACTLY ONE OF the created recipients, the failed list (Invariant 1.2).
Check 1.4: An auditor MUST compare the sum of the created list's count AND the failed list's count with the reconstructed set's count (Invariant 1.2).
Check 1.5: An auditor MUST read a count mismatch moved by a stamp inside the boundary window as boundary-adjacent (Capability requirement 6, Capability requirement 7).
Check 1.6: An auditor MUST read a count mismatch no stamp inside the boundary window moves as an Invariant 1.2 violation.
Check 2.1: An auditor MUST find EVERY notification record of one fanout carrying the same payload (Invariant 2.1).
Check 3.1: An auditor MUST find EVERY notification record of a fanout carrying the record's own status (Invariant 3.2).
Check 3.2: An auditor MUST find no notification record of a fanout referencing another notification record (Invariant 3.3).
Check 4.1: An auditor MUST find no two created-list notification ids of one fanout naming one subscriber reference (Invariant 4.1).
Check 5.1: An auditor MUST find no subscription record written by the composition (Invariant 5.1).

NOTE: EVERY check names the rule the check tests.

Term clock offset allowance: clock offset allowance — the declared envelope within which the composition’s firing instant may be compared with a stamp Subscription wrote at its own seam.

Term boundary window: the instants from (firing instant - clock offset allowance) through (firing instant + (read latency bound + clock offset allowance)), both ends included — a Boundary Window.

External checks

External check 1: An auditor needing create-time isolation confirmed MUST read the deployment's own transaction configuration (Invariant 3.1).
External check 2: An auditor needing a fanout grouped MUST read a composed Event Log (Composition state 3, Composition state 3a).
External check 3: An auditor needing the boundary window computed MUST read the deployment's disclosed bounds (Capability requirement 6, Capability requirement 7).
Deleted: External check 4. Execution Contract Conformance 9 owns it.
External check 5: An auditor needing a notification record verified MUST read Notification's own acceptance (Composes 7).
WHY

Check 1.3 pairs each created notification id with its subscriber at a cost: the created list carries ids and the failed list carries subscriber references, so an auditor makes one status_of call per created id to read its recipient.


Non-goals

Non-goal 1: The composition MUST NOT guarantee idempotency across two fanouts.
Deleted: Non-goal 2. Composition state 4 owns it.
Non-goal 3: The composition MUST NOT answer a result for a fanout that crashed.
Non-goal 4: The composition MUST NOT read the subscriber set twice in one fanout.
Non-goal 5: The composition MUST NOT skip a subscriber reference the subscribers_for answer carried.
Non-goal 6: The composition MUST NOT serve a subscriber reference the subscribers_for answer did not carry.
Non-goal 7: The composition MUST NOT expand an event scope.
Non-goal 8: The composition MUST NOT match an event scope by pattern.
Non-goal 9: The composition MUST NOT order a create.
Non-goal 10: The composition MUST NOT deliver a notification record.
Non-goal 11: The composition MUST NOT authorize a caller.
Non-goal 12: A deployment needing an authorized fanout MUST compose Permissions.
Non-goal 13: The composition MUST NOT validate a payload's schema.
Non-goal 14: The composition MUST NOT bound the fan-out's cost.

NOTE: the owners — the delivery layer, a transport reading Notification’s pending_for and calling deliver, fail or expire (Non-goal 10).


Edge cases

Clock semantics

Clock semantics 1: The firing instant MUST stand as a lower bound on the instant the database fixed the subscriber set.
Clock semantics 2: The composition MUST NOT claim the firing instant as the instant the database fixed the subscriber set.
Clock semantics 3: The composition MUST NOT claim the firing instant equal to a notification record's creation instant.
Clock semantics 4: The composition MUST answer the firing instant to the caller.

Indeterminate outcome

Deleted: Indeterminate outcome 1. Execution Contract Error model 1 owns it.
Deleted: Indeterminate outcome 2. Execution Contract Error model 1 owns it.
Indeterminate outcome 3: A caller retrying a failed subscriber reference MUST call Notification's create for the subscriber reference.
Deleted: Indeterminate outcome 4. Execution Contract Error model 1 owns it.
Indeterminate outcome 5: A caller MAY read every subscriber reference failing alike as the payload's fault.
Indeterminate outcome 6: A caller MAY read one subscriber reference failing alone as the subscriber reference's fault.
Indeterminate outcome 7: A caller needing at-most-once across an indeterminate create MUST compose Duplicate Prevention PER Composition state 5 AND Composition state 5a (Notification Composition note 5).
Indeterminate outcome 7a: A caller needing at-most-once across an indeterminate create MUST NOT retry a failed subscriber reference (Action wiring 18).
Indeterminate outcome 8: A caller MUST NOT read Notification's pending_for as finding an indeterminate create's record (Execution Contract Error model 1).

NOTE: closed 2026-10-29-a — at-most-once is the caller’s event identity recorded before the fanout AND no failed subscriber reference retried; no create carries a key, and a guard first checked at retry is no guard (Decisions 2026-11-04).


Composition notes

Composition note 1: A deployment MUST declare which composing patterns the deployment wired in.
Composition note 2: A deployment MUST own the caller's authority to fan out.
Composition note 3: A deployment MUST own a payload's schema, size and retention.
Composition note 4: A deployment MUST own the disposition of a failed list.
Composition note 5: A deployment MUST own the fan-out's throughput strategy.
Composition note 6: A deployment MUST own the delivery window.
Composition note 7: A deployment MUST own a subscriber's deprovisioning cascade.

Terms

The canonical concepts this spec refers to. Each [Term] marker in the prose above links to its term entry here. A term entry states what the concept is, in plain English, plus its Kind — one of five: 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 term entry 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 term entry carries one Projection 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 takes from the seam, the Firing Instant, a lower bound on the instant the subscriber set was fixed, plus the Created and Failed lists that partition that set — and its own Subscribers Unavailable rejection. The composition keeps no state of its own (Composition state: none), so there are no records of its own to carry a term entry. 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 composition’s own boundary rejection (invalid-request, Primitive policy 1 through 2, raised before any constituent call) remain qualified/backticked, not carded here (a failed create — a refusal, a failed read’s state-unavailable, or no answer within the create bound — is classified at the boundary by Action wiring 11 and absorbed into Failed). (annotation.md Terms registry; representational only — it changes no guarantee, invariant, or behavior of the composition above.)

Vocabulary

Term qualifiers: migrated — rewritten in GRACE lang v0.40 (2026-09-14).

Term terms: composition, constituents, seam, transition, read latency bound, blank, boundary predicate, admitted fanout, created list, failed list, created recipients, unrecordable create, clock offset allowance, boundary window, fanout result.

Term record verbs: call, answer, take, read, write, record, validate, compare, normalize, bound, stand, carry, claim, find, name, own, discharge, inherit, change, serve, compose, declare, disclose, supply, draw, mint, commit, order, continue, abort, account, skip, expand, match, deliver, authorize, reconstruct, sum, persist, guarantee, accept, retry, follow, reference, share, store, meet.

Term actors: the composition; the constituents; the host; the transition; a deployment; an auditor; a caller; a subscriber; a notification record; a subscription record.

Term cited: Execution Contract Conformance 8 — recursive conformance and the inherited guarantee. Execution Contract Conformance 9 — the wiring read from the deployment’s own declaration. Execution Contract Constituent failure 1 — a constituent’s storage-failure, landed by a rule citing the label. Execution Contract Constituent failure 3 — a constituent’s state-unavailable, landed by a rule citing the label. The section titled Boundary rules in execution-contract.md — no rollback guarantee across a parallel composition’s calls. The section titled Composition state in execution-contract.md — the no-stored-state classification and the record-coordination rule. The section titled Logic Confinement Principle in execution-contract.md — the seam and the transition. pending_for: Notification.

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, the Created and Failed lists and the Firing Instant, or rejects Subscribers Unavailable (the database read failed or went unanswered) 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 takes from the seam 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 Projection: fanout_id

Created

The result list of notification_ids for the subscribers whose Notification.create succeeded in this fanout; the created recipients are read from those records through Notification.status_of.

Kind: Field Field of: the fanout result Role: the succeeded recipients Projection: created

Firing Instant

The seam-injected clock reading the host supplied when the invocation began — a lower bound on the instant subscribers_for fixed the subscriber set, which the composition never observes. Returned so the caller’s Event Log entry can pin the instant the composition held; the caller never reproduces it. It is the composition’s one clock use (Logic confinement) and is a different seam’s reading from each record’s created_at.

Kind: Field Field of: the fanout result Role: the lower bound on the instant the subscriber set was fixed Projection: fired_at

Failed

The result list of subscriber_refs for whom no delivery record was observed — the constituent’s declared refusals and a failed read’s state-unavailable (no record exists) and the unrecordable create (a record may exist with an id the composition never saw) collapsed into one “delivery not observed” outcome, which is why a retry may leave a second record (Execution Contract Error model 1). Together with the created recipients it accounts for every subscriber in the query result (Invariant 1); 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) Projection: failed

Subscribers Unavailable

The composition’s own rejection from Fanout — returned when the read of the subscription records (Subscription.subscribers_for) fails with an infrastructure error, answering state-unavailable, or answers nothing within the Read Latency Bound (Action wiring 4). 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 Projection: subscribers-unavailable

Entropy Floor

The uniqueness bound this composition places on its host’s id source: 128 bits of entropy per id, or a generator whose coordination gives the same uniqueness. Subscription declares the same floor for its own record ids and Notification declares none, so the bound is attributed to neither constituent and stated here as a Capability requirement (Capability requirement 3, Invariant 8.1).

Kind: Parameter Parameter of: the host Projection: entropy_floor

Read Latency Bound

The deployment’s disclosed bound on the interval from the Firing Instant to the answer of the subscribers_for call, past which a call that has not answered lands as Subscribers Unavailable (Capability requirement 11, Action wiring 4) — a hung read otherwise hangs the fan-out. An operating fact rather than a record, and one of the two terms of the Boundary Window an auditor needs to read a coverage mismatch correctly (Capability requirement 6, Check 1.5).

Kind: Parameter Parameter of: the deployment Projection: read_latency_bound

Boundary Window

The instants from the Firing Instant less the clock offset allowance through the Firing Instant plus the Read Latency Bound and the clock offset allowance, both ends included: the database fixes the subscriber set inside the read latency bound after the firing instant, and a stamp Subscription wrote may sit up to the clock offset allowance either side of the composition’s clock. A subscribe or a cancel stamped inside it can move a reconstructed subscriber count by one, so a coverage mismatch inside the window is boundary-adjacent and one outside it is a violation (Check 1.5, Check 1.6).

Kind: Type Projection: boundary_window


Standards references

  • Observer pattern (GoF — the “Gang of Four”, the four authors of Design Patterns, 1994) — 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 (Advanced Message Queuing Protocol — the open messaging-middleware standard) topic exchanges; Apache Kafka consumer groups) — the event-scope-to-subscriber binding is a topic subscription; Fanout is message dispatch.
  • WebSub (W3C — World Wide Web Consortium, the web’s standards body — 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, XMPP PubSub (XEP-0060 — publish-subscribe over the Extensible Messaging and Presence Protocol).
  • Notification — standards inheritance in full: Observer pattern, SMTP (Simple Mail Transfer Protocol — the standard email-delivery protocol), HTTP (Hypertext Transfer Protocol) webhooks, W3C Activity Streams, APNs/FCM (Apple Push Notification service / Firebase Cloud Messaging — the iOS and Android push-delivery services).

Status

grounded on Final Critique 11 — 2026-11-05 at 63c13eb — see the Ledger.

Ledger

status: grounded on Final Critique 11 — 2026-11-05 at 63c13eb
formal: not applicable — vote no 2026-06-03: single-invocation structural coverage properties, no ordering or concurrency claim
last gate: 2026-11-05 — Final Critique 11, a delta round, frozen at 63c13eb — foundational count 0, clean, no sample; 2026-11-04 — Final Critique 10 — foundational count 4, not clean, no sample

open: none

Decisions

Directional changes only — the turns a future reader must know the pattern took, and why. Everything smaller lives in the commit that made it: git log -- compositions/notification-fanout.md; the migration, the council read’s collapse, the two readings of the gate’s open lines and the regeneration that got it here are in its history.

  • 2026-11-04 — At-most-once is a guard recorded before the fanout, and no failed create is retried. Chose: Duplicate Prevention keyed by the caller’s event identity, checked AND recorded before the fanout is called. A caller needing at-most-once retries no failed subscriber reference. Idempotent Reservation is withdrawn as the route. Over: Idempotent Reservation over Notification.create, whose Composes holds no Notification, so no deployment can wire it. Also over a per-create guard keyed by the event identity and the subscriber reference, which the fanout’s signature cannot carry and which a retry would be first to consult. Because: only a guard recorded before the original create sees it (2026-08-28). The fanout answers no create’s reason, so a caller cannot tell a refused create from an unrecordable one. The cost is named: a crashed or partly failed fanout leaves some subscribers with no record. That is at-most-once, not exactly-once.
  • 2026-10-22 — The wiring is read outside the records, as the Execution Contract says. Chose: External check 4, the auditor reading the composing patterns from the deployment’s own declaration, deleted to Execution Contract Conformance 9, which the cited line now names beside Conformance 8; rules 109 to 108. Over: the page stating for itself that the wiring is an external check. Because: the maintainer ruled on 2026-10-22 that five repeated cures are each stated once, the wiring declaration among them; twelve atoms had moved the same check to their external checks one specification at a time, and this page carried it in the same place under other words. What each action does and answers is unchanged.

  • 2026-08-28 — A retry of an indeterminate create is at-least-once, and the page says so. Chose: withdraw the pending_for reconciliation and the retry-side Duplicate Prevention claim; scope Invariant 4 to observed records; name Idempotent Reservation over Notification.create as the deployment’s route to at-most-once. Over: keeping a reconciliation that read Pending-only ids and matched on payload. Because: pending_for cannot see a record the delivery layer has moved on, a payload is not a fanout identity, and a guard first consulted at retry never saw the original create — the reconciliation promised what no read of the bare atoms can deliver.
  • 2026-08-28 — firing instant is a lower bound on the instant the set was fixed. Chose: the seam reading the invocation began under, with check 1’s window widened by a disclosed max_read_latency. Over: a stamp “taken immediately before the query” described as the instant the set was fixed. Because: the store fixes the set when it executes the read, an instant the composition never observes; the earlier wording named a moment nothing on the page can see and let check 1 convict a subscribe that landed inside the read’s latency.

NOTE: End of Notification Fanout.


↑