Notification Fanout

Composition partially resolved 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
      5. Wiring decision
    5. Composition-level invariants
    6. Examples
      1. Walkthrough
      2. Invalid input
      3. Subscription store 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
    15. Decisions

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 when the subscription store answers 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 the invocation runs to completion — a crash mid-fanout 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 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 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 store through Subscription's subscribers_for.
Composes 6: The composition MUST NOT write to the notification store 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.

WHY: Composes 7 is one rule where the prose carried two. The prose named the two deleted invariants preservation claims and distinguished them from the six that emerge — which is the right distinction and the reason the migration could act on it cleanly. Execution Contract Conformance 8 settles both: conformance extends recursively and no composing layer is obligated to re-verify what a constituent’s own conformance establishes, so asserting it twice more was a citing spec restating a rule it cites (Authority 6, council read 53, council read 55).

Composes 5 and Composes 6 are what the preservation claims carried beyond the blanket, and none of it is inherited: the read-only posture toward the subscription store and the refusal to reach past create are this composition’s own restraint, not a guarantee either atom makes about a caller.


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 4: A deployment needing a fanout deduplicated MUST compose Duplicate Prevention.
Composition state 5: A deployment composing Duplicate Prevention MUST supply a caller's event identity as the fanout's key.
Composition state 6: EVERY notification record of a fanout MUST name EXACTLY ONE subscriber reference.
Composition state 7: A subscriber reference the subscribers_for answer carried MAY carry no notification record of the fanout.

WHY: The contract classification is conforming, no stored composition state (the section titled Composition state in execution-contract.md). The subscription store owns who subscribes and the notification store owns what was created; the composition is a stateless interpreter over both. Composition state 3 is the Contract’s record-coordination rule applied by name — a composition that must record that its own sequences occurred composes Event Log rather than growing a store of its own — and this composition routes the concept out rather than holding it. Composition state 5 says where Duplicate Prevention’s key comes from, since fanout(event_scope, payload) carries no event identity of its own. Composition state 6 and Composition state 7 are the relation’s two sides: one subscriber per record, and a subscriber whose create failed holding none.

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.
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.
Capability requirement 5: The transition MUST NOT mint an id.
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.

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 wall-time reading the host takes at the seam and hands to the transition, as the section titled Logic Confinement Principle in execution-contract.md declares it; never read inside the transition, never supplied by the business caller.

Term transition: the composition’s evaluation of one Fanout call against the two stores, 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 between the composition dispatching subscribers_for and the subscription store executing it — a Read Latency Bound.

WHY: Capability requirement 3 is a floor neither constituent supplies. Subscription declares the same floor for its own record ids and Notification declares none, so the requirement is this composition’s dependency on its host and is attributed to neither atom — which is what makes it a Capability requirement rather than an inherited guarantee.

Capability requirement 10 is what EXACTLY ONE time in Action wiring 7 means below the composition: a transport that resent a create on its own would make two calls the composition counted as one.

Capability requirement 6 and Capability requirement 7 are the two halves of the boundary window Check 1 needs. Neither is a record, both are operating facts a deployment states, and without them the window is not computable and the audit degrades to a caveat.

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.

WHY: invalid-request here is the composition’s own and nothing is inherited. The event scope check is consistent with Subscription’s write surface, which refuses a blank event scope; the payload check refuses only an absent payload, because Notification’s create accepts an empty one (Notification Operation 5), so an empty payload is a payload — but this composition never calls subscribe, so no constituent contract governs the check and no constituent is consulted when it fires (Ledger 2026-08-27-k: the provenance is stated once, here).

Primitive policy 7 is a deliberate absence. A payload cap is the composing system’s before Fanout is called; what the composition does carry is the consequence — an oversized payload is refused by every create and lands every subscriber in the failed list, which is the shape Indeterminate outcome 5 tells a caller to read.

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 the subscription store answers unavailable THEN [Fanout] MUST answer subscribers-unavailable.
Action wiring 5: A subscribers-unavailable answer MUST NOT carry a fanout id.
Action wiring 6: A subscribers-unavailable answer MUST NOT follow a create.
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: An admitted fanout MUST call Notification's create 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 THEN an admitted fanout MUST record the subscriber reference in the failed list.
Action wiring 12: An admitted fanout MUST call Notification's create for EVERY remaining subscriber reference the failed create did not name.
Action wiring 13: An admitted fanout 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.
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 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 the composition saw answer otherwise — a Failed list.

Term unrecordable create: a create answering neither a notification id nor one of Notification’s declared refusals — 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).

WHY: Action wiring 11 and Action wiring 12 are the load-bearing decision stated as rules: the fan-out continues and names its failures rather than aborting. Action wiring 15 is what makes that honest — parallel composition carries no rollback guarantee, so each create commits independently and there is no transaction to abort into.

Action wiring 18 is the reason the failed list carries a subscriber_ref and not a reason. Three failures collapse into it — the structural invalid-request and the storage-failure Notification declares, each recording nothing, and the unrecordable create the boundary named, which may have — and the composition cannot tell them apart without retrying and watching. A caller needing reason-level diagnostics composes Event Log at the call site, which Composition state 3 already routes.

Action wiring 5 says the rejection carries no id because the invocation did not happen. An id on a rejection would give a caller a correlation handle for an invocation that produced nothing to correlate.

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 list, the failed list.

WHY: The alternative is all-or-nothing, and it fails the case it exists for: a store 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. 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.

Wiring decision 3 is bounded by what a completed call can claim. A crash inside the fan-out produces no result and therefore no account of anybody — the coverage claim is over an invocation that answered, and Invariant 1 carries the same bound (Ledger 2026-08-27-g).


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 list, the failed list.
    Invariant 1.3: The composition MUST NOT call Notification's create for a subscriber reference outside the subscribers_for answer.
    

    WHY: the claim is over an invocation that answered. A crash inside the fan-out produces no result and no account of anybody, which is the bound Wiring decision 3 carries and the Summary states.

  • Invariant 2 — Payload consistency.
    Invariant 2.1: EVERY notification record of one admitted fanout MUST carry one 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.
    

    WHY: Invariant 4.2 is the scope and it is deliberate. An indeterminate create may have committed a record whose id never came back, nothing in Notification’s declared surface lets anyone find it afterwards, and a retry can therefore produce a second record. That residual is the caller’s (Indeterminate outcome 4) and is not a breach of an invariant that never claimed to see the unseen.

  • Invariant 5 — The subscription store is read-only.
    Invariant 5.1: The composition MUST NOT write to the subscription store.
    Deleted: Invariant 6. Composes 7 owns it.
    Deleted: Invariant 7. Composes 7 owns it.
    

    WHY: the two deleted invariants asserted that Notification’s and Subscription’s own invariants hold over this composition’s instances. The prose named them preservation claims and set them apart from the six that emerge, which is the right distinction and the reason they could be collapsed cleanly: Execution Contract Conformance 8 already establishes recursive conformance, so restating it twice was a citing spec restating a rule it cites (Authority 6). What they carried beyond the blanket is Composes 5 and Composes 6.

  • 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.
    

    WHY: the uniqueness rests on Capability requirement 3’s entropy floor, which is the composition’s own dependency on its host because neither constituent supplies it — Subscription declares the same floor for its own ids and Notification declares none.


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 store unavailable

The subscription store is 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 store recovers.

Partial failure

During a fanout, the notification store becomes 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) → the write fails against the unavailable store: no notification_id, no conforming constituent outcome — 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 Indeterminate outcome 4, knowing what that buys: dev_b’s entry was indeterminate, so if the failed write had in fact committed a record before the store 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 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 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 store 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 store does support historical reconstruction of who was Active at any given moment — Subscription 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 Subscription 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 the subscribers_for query — 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 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 store for records where recipient_ref = officer_ref and payload.policy_id = p12. If a record exists in any state, the store confirms the delivery attempt and its outcome. If the record shows failed_at or expired_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 failed — the boundary-classified write failure or Notification’s own invalid-request, either 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 or Duplicate Prevention can show. 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 = 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 store answers the exposure scope from stored fields alone.

Generation acceptance

A derived implementation is acceptable when an external auditor, given the subscription store and the notification store, can clear the checks below without recourse to source code, runbooks or developer narration. Check 1 and Check 2 are clearable only where Event Log is composed in, and the preamble says so rather than opening universally: the composition persists no fanout id, so nothing in the bare stores groups a fanout (Ledger 2026-08-27-h).

Conformance checks

Check 1.1: An auditor MUST read a fanout's event scope and firing instant from the composed Event Log entry (Invariant 8.3).
Check 1.2: An auditor MUST reconstruct the active subscriber set at the firing instant from the subscription store's 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 list, the failed list (Invariant 1.2).
Check 1.4: An auditor MUST find the created list's count AND the failed list's count summing to the reconstructed set's count (Invariant 1.2).
Check 1.5: An auditor MUST read a count mismatch inside the boundary window as boundary-adjacent (Capability requirement 6, Capability requirement 7).
Check 1.6: An auditor MUST read a count mismatch outside the boundary window as an Invariant 1.2 violation.
Check 2.1: An auditor MUST find EVERY notification record of one fanout carrying one 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 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 interval the read latency bound and the clock offset allowance together span around a firing instant — 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 2).
External check 3: An auditor needing the boundary window computed MUST read the deployment's disclosed bounds (Capability requirement 6, Capability requirement 7).
External check 4: An auditor needing the composing patterns named MUST read the deployment's own declaration (Composition note 1).
External check 5: An auditor needing a notification record verified MUST read Notification's own acceptance (Composes 7).

WHY: The split is the record-versus-attempt line the epoch keeps finding. What stands clears from the stores — a record’s payload, its status, its recipient, the absence of a subscription write. What was attempted does not: a create that did not record leaves nothing to inspect, so Invariant 3.1’s create-time clause is answered from the deployment’s transaction configuration or a fault-injection test and is an external check rather than a record-clearable one (Ledger 2026-08-27-i, which had Notification’s own acceptance classified both ways — it is classified once here, as External check 5, because it is the constituent’s acceptance and not this composition’s).

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.

Check 1.5 and Check 1.6 are the boundary window doing real work. A subscribe or a cancel stamped inside the window can move the reconstructed count by one, and an auditor who read that as a violation would be filing against a correct implementation; an auditor who read every mismatch as boundary-adjacent would never file at all. The window is computable only from the two disclosed bounds, which is why they are capability requirements rather than advice.


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.

WHY: Non-goal 5 and Non-goal 6 are one decision seen from two sides: the subscriber set is the active set at the instant the store executed the query, and the composition neither re-reads it nor filters it afterwards. A subscriber who cancels between the query and their create still receives a record, and one who subscribes after does not — both correct, both consequences of reading once.

Non-goal 3 is the crash bound. A fan-out that dies mid-flight answers nothing, so there is no result to carry an account and no entry for a composed Event Log to hold; the coverage claim is over invocations that answered and says nothing about the ones that did not.

Non-goal 10 is the boundary with the delivery layer. This composition creates records; a transport reads Notification’s pending_for and calls deliver, fail or expire. That the two are separate is why an indeterminate create cannot be reconciled from pending_for alone (Indeterminate outcome 8).


Edge cases

Clock semantics

Clock semantics 1: The firing instant MUST stand as a lower bound on the instant the subscription store fixed the subscriber set.
Clock semantics 2: The composition MUST NOT claim the firing instant as the instant the subscription store 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.

WHY: The set is fixed when the subscription store executes the read, which the composition never observes — subscribers_for takes and returns no instant — and validation, dispatch and read latency stand between the seam reading and that execution. An earlier draft called firing instant the instant the subscriber set is fixed, which named a moment nothing on this page can see. Clock semantics 4 is why the field is returned at all: the caller cannot observe that instant either, so asking a caller to log its own invocation time would pin the wrong one and silently widen the very window Check 1 bounds.

Indeterminate outcome

Indeterminate outcome 1: An unrecordable create MUST stand as indeterminate.
Indeterminate outcome 2: The composition MUST NOT read an unrecordable create as no record.
Indeterminate outcome 3: A caller retrying a failed subscriber reference MUST call Notification's create for the subscriber reference.
Indeterminate outcome 4: A caller retrying an indeterminate subscriber reference MUST accept a second notification record.
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 Idempotent Reservation.
Indeterminate outcome 8: A caller MUST NOT read Notification's pending_for as finding an indeterminate create's record.

WHY: A create that times out or comes back unacknowledged may have committed a record whose id never returned, and no reconciliation from the bare atoms can find it. pending_for answers only records still pending, so one the delivery layer already moved is invisible to it (Indeterminate outcome 8); a payload is not a fanout identity, since Notification permits any number of records over one (recipient_ref, payload) pair and concurrent fanouts legitimately share a payload; and create declares no idempotency key by which a retry could name the record it means. A guard composed around the retry alone sees nothing of the original create and admits the retry.

So a retry of an indeterminate entry is at-least-once and a second record is a residual the caller accepts, which is why Invariant 4 is scoped to the records the composition observed. Indeterminate outcome 7 names the cure rather than promising it: closing this needs an idempotency key at a store that can honor one, which is outside this composition’s surface.

Indeterminate outcome 5 and Indeterminate outcome 6 are the discriminator the composition can offer without a reason field. Every subscriber failing identically is the payload outside Notification’s acceptance; one subscriber failing alone is that subscriber_ref outside it — Subscription imposes no length cap where Notification does, and that enumerable divergence is where a single structural failure comes from (Ledger 2026-08-27-l). Both are heuristics, which is why they are MAY: a notification store down for every create fails every subscriber alike too, and a fan-out of one cannot tell the two readings apart.


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 and size.
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.

WHY: Composition note 4 is the mechanism-versus-policy split written as an obligation. The composition cannot know whether a missed delivery matters — an activity feed accepts the loss and a regulated broadcast escalates — so it names the gap and hands the decision to the layer that knows the event’s stakes.


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 instant at which it fixed the subscriber set, 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 is no record store 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, inherited from neither constituent) remain qualified/backticked, not carded here (the write-side infrastructure failure carries no constituent token — it is the boundary-owned classification of action wiring step 4, 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, admitted fanout, created list, failed list, 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. 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 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 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.

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 invalid-request (no record exists) and the boundary-classified indeterminate outcome (a record may exist with an id the composition never saw) collapsed into one “delivery not observed” outcome, which is why a retry reconciles first. 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) Projection: 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 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 between the composition dispatching subscribers_for and the subscription store executing it. An operating fact rather than a record, and one half 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 interval the Read Latency Bound and the clock offset allowance together span around a Firing Instant. 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

partially resolved — see the Ledger.

Ledger

status: partially resolved
formal: not applicable — vote no 2026-06-03: single-invocation structural coverage properties, no ordering or concurrency claim
last gate: 2026-08-28 — second gate after closure, fresh reader — 4 foundational (all since closed), 13 refining, 4 rhetorical

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.

  • 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.

  • 2026-09-14 — Rewritten in GRACE lang v0.40; nothing but language changed except two invariants the Execution Contract already owns. Chose: Composes, Composition state, Capability requirement, Primitive policy, Action wiring, Wiring decision, Clock semantics and Indeterminate outcome as the wiring surfaces, the six surviving invariant numbers unchanged, and the acceptance section’s own two-tier split carried across as Check and External check. Over: the prose spec. Because: the migration plan; nothing in the corpus cites this composition by label. Two families are worth naming: the Retry semantics section is Indeterminate outcome — the family Approval Step, Medication Order and Party Identity already carry — so it was taken rather than minted, which puts that family at four specs and makes this the first composition to hold it. And Logic confinement is Capability requirement, the standard family, because what the section states is what the host must supply.
  • 2026-09-14 — Two preservation claims are one citation. Chose: Composes 7, with Invariant 6 and Invariant 7 tombstoned to it. Over: keeping them. Because: the prose already drew the line the ruling needs — Invariant 1 through 5 and 8 emerge from the composition; Invariants 6 and 7 are preservation claims — and Execution Contract Conformance 8 settles a preservation claim by reference, so restating it twice was a citing spec restating a rule it cites (Authority 6, council read 53, council read 55). What the two carried beyond the blanket survives as Composes 5 and Composes 6: reading the subscription store only through subscribers_for and refusing to reach past create are this composition’s own restraint and are not guarantees either atom makes about a caller.
  • 2026-09-14 — Five of the Ledger’s six open lines are closed by the migration. Chose: to close 2026-08-27-g, -h, -i, -k and -l in the rewrite and strike them from the Ledger’s open list, leaving -j. Over: migrating the language and leaving six known defects standing behind it. Because: all five were language or ownership defects a rewrite is the natural moment to fix — an unconditional coverage claim that needed the crash bound (Wiring decision 3, Non-goal 3, Invariant 1’s WHY), an acceptance preamble opening universally where two checks need a composed Event Log (now conditional, and stated in the preamble), Notification’s own acceptance classified both record-clearable and external (classified once, as External check 5), invalid-request’s provenance stated two ways (stated once, in Primitive policy’s WHY), and the two constituents’ divergent non-empty definitions left unnamed (named in Indeterminate outcome’s WHY — Subscription admits a whitespace-only ref and caps nothing, Notification refuses both). -j stays open because it is a design choice rather than a defect: an Event Log entry carrying the full created and failed lists is unbounded in N against Event Log’s payload cap, and bounding, chunking or digesting it is the maintainer’s to pick. The status:, formal: and last gate: lines are untouched (council read 56).

  • 2026-09-28 — The 2026-08 gate’s open lines, read against the rewritten page. Chose: of 28 open lines, 9 were already closed by the rewrite and are removed; 19 were live and are cured here — the create bound, its disclosure and no transport retry (Capability requirement 8 through 10); Duplicate Prevention keyed by a caller-supplied event identity and the fanout-to-record relation’s two sides (Composition state 5 through 7); the failure discriminator as a caller’s heuristic (Indeterminate outcome 5, 6, now MAY); the cost of pairing a created id with its subscriber stated; and the summary, examples, Fanout Id entry and standards brought to the rules, including two references to sections that no longer exist. Over: carrying lines whose anchors — step 4, Retry semantics, the externally-clearable list — the rewrite removed. Because: a Ledger that names sections the page no longer has cannot be read against the page.

  • 2026-09-28 — The page brought to its constituents as they now stand. Chose: the unrecordable create named by what it is — no id and none of Notification’s declared refusals — with Notification’s storage-failure counted as the declared, recordless refusal it now is; the payload check narrowed to an absent payload (Primitive policy 2), which is what the prose checked, since Notification accepts an empty one; Check 1.2 reading the subscription store’s stored instants rather than a filter Subscription does not declare; and the one-subscriber-failing reading narrowed to the length cap, since Subscription now refuses a whitespace-only ref. Over: the page’s claims about Notification and Subscription as they stood before 2026-09-15. Because: the cold regeneration of 2026-09-28 built against the constituents and met each claim false.

NOTE: End of Notification Fanout.


↑