Preference-Aware Notification Fanout

Composition partially resolved 10 invariants TLA+ model buggy twin ×1 regulated composes 4 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. Preference-Aware Notification Fanout
    1. Summary
    2. Intent
    3. Composes
    4. Composition logic
      1. Composition state
      2. Configuration
      3. Primitive policies
      4. Action wiring
      5. Replay semantics
      6. The load-bearing wiring decision
    5. Composition-level invariants
    6. Examples
      1. Walkthrough — one invocation, all three dispositions
      2. Frequency cap firing
      3. Best-effort cap overshoot and reconciliation
      4. Statutory window firing — no stored quiet hours
      5. Marketing newsletter — channel opt-out under CAN-SPAM
      6. Rejection paths
      7. Fail-closed gate — preference store outage
      8. Partial failure
      9. Regulated adversarial scenarios
    7. Generation acceptance
      1. Record-clearable checks
      2. External checks
    8. Non-goals and edge cases
    9. Terms
      1. Fanout Shaped
      2. Redispose
      3. Reconcile Gaps
      4. Reconcile Overshoots
      5. Shaping Disposition
      6. Fanout Id
      7. Retry Eligible
      8. Evaluation Inputs
      9. Suspended
      10. Quiet Window
      11. Frequency Cap
      12. No Record
      13. Channel Opt Out
      14. Unsubscribed
      15. Preference Unreadable
      16. Accounting Unreadable
      17. Interpretation Undeclared
      18. Journal Rejected
      19. Yielded
      20. Too Young
      21. Orphan Pending
    10. Standards references
    11. Status
    12. Ledger
    13. Decisions

Summary

Preference-Aware Notification Fanout connects an event to everyone who asked to hear about it — delivered the way each of them wants. It wires together four simpler patterns: one for who is interested in which events (Subscription); one for each person’s delivery wishes — preferred channels, “no more than N a day,” “nothing between 10pm and 7am,” plain versus rich format (Message Preference); one that creates a per-recipient delivery record and tracks its outcome (Notification); and one that keeps a permanent ordered journal of everything that happened (Event Log). When an event fires, it asks who is subscribed and, for each subscriber, reads their current preferences and decides: deliver on these channels in this format, or don’t — for a named reason (paused, inside quiet hours, over their frequency limit, or opted out of every channel). The guarantee none of the four can make alone: every subscriber gets exactly one recorded disposition — delivered, failed, or suppressed-with-a-reason — and nothing is silently dropped, so the deployment can later prove from the journal that it honored quiet-hours and opt-out rules, the evidence telemarketing and commercial-email regulations effectively demand.


Intent

Notification Fanout answers who should receive this? and produces one delivery record per Active subscriber. What it cannot answer is how does this subscriber want delivery shaped — and is right now even an acceptable moment? The Message Preference atom records those answers: channels, frequency limits, quiet hours, format. But by its own declaration Message Preference does not deliver, does not consult subscriptions, and does not interpret its own opaque values — it names “the composing fanout pattern” as the interpreter. The interpretation point — the moment a fanout invocation reads a subscriber’s in-effect preference record and renders a delivery-or-suppress verdict — belongs to no atom. It is this composition’s reason to exist.

The stakes of that interpretation point are regulatory, not cosmetic. The TCPA (Telephone Consumer Protection Act, codified at section 227 of title 47 of the United States Code (U.S.C.) — the US federal law restricting unsolicited calls and text messages) attaches per-message statutory liability to a text sent inside a recipient’s quiet window; CAN-SPAM (the US Controlling the Assault of Non-Solicited Pornography And Marketing Act, 15 U.S.C. §7701 et seq.) requires honored opt-outs in commercial email. A fanout loop that consults preferences “usually” delivers inside quiet windows whenever a code path forgets to check. This composition makes the check structural. The shaping gate sits between Subscription.subscribers_for and each Notification.create, so every subscriber passes through it on every disposition — the original invocation’s pass and any later journaled retry. Its verdict is recorded whether it delivers or suppresses. A suppressed subscriber is not a skipped subscriber — the suppression is a first-class disposition, classified by reason, written to the Event Log, and queryable later by the regulator, the disputing recipient, or the breach investigator.

This composition does not compose Notification Fanout. Notification Fanout’s fanout action exposes no per-subscriber hook; wrapping it cannot insert the shaping gate between the query and the creates without a breaking change to a grounded composition. The precedent is Reserve from Pool, the pool-arithmetic superset of Idempotent Reservation: re-wire the same atoms plus more, rather than wrap the smaller composition. Notification Fanout remains the unshaped sibling — deployments that need fanout without preference shaping use it directly. This composition mirrors its loop, its failure isolation, and its fanout_id correlation discipline, widening the bipartition {created, failed} to the trichotomy {created, failed, suppressed}.

Two boundary declarations the constituents themselves draw, restated here because the composing layer must honor the sequencing. First, legal permission is not evaluated here: whether the system may communicate with the principal at all is the Consent pattern’s question, and Message Preference’s own blockquote draws that line. A deployment whose deliveries require lawful basis sequences the Consent gate before invoking this composition; Consent and Propagate Consent Revocation Downstream are named peers, not constituents. Second, attribution is not provided here: a deployment that must answer who triggered this fanout under credential composes Audit Trail or Actor Identity alongside; the bare composition records dispositions, not initiators.


Composes

  • Subscription — provides the Active subscriber set via the subscribers_for(event_scope) query, and the point query subscribed(subscriber_ref, event_scope) that Redispose uses to re-verify the audience across its unbounded retry horizon. The composition reads but never writes the subscription store; those two declared queries are the only Subscription surfaces invoked.
  • Notification — provides the per-recipient delivery record via create(recipient_ref, payload), the status_of(notification_id) query that Redispose reads to adjudicate transport-failure retries, and the pending_for(recipient_ref) query that Reconcile Gaps reads to find a delivery record whose journal half never landed — the deliver-pair orphan (Edge cases, Cross-store consistency under partial failure). The composition creates at most one live Notification record per subscriber per invocation, with the shaped envelope carried in the payload. Delivery outcome tracking (deliver / fail / expire) belongs to the deployment’s transport layer, not to this composition — the composition only reads those outcomes at the retry boundary.
  • Message Preference — provides each principal’s in-effect delivery-shaping record via current_for(principal_ref) (and read(preference_id) for audit reconstruction). The composition reads but never writes the preference store. The composition is the interpreter Message Preference’s own spec names: it gives the opaque channel_preferences, frequency_limit, quiet_hours, and format values their operational meaning at disposition time, under deployment-declared interpretation rules (see Configuration).
  • Event Log — provides the durable disposition journal via append(data) and the replay surface via read(query). Every fanout invocation and every per-subscriber disposition is appended; the frequency-cap accounting is derived by reading delivery events back (see Composition state). Where Notification Fanout treats Event Log as an optional caller-composed enrichment, this composition makes it a constituent: the no-silent-disposition guarantee (Invariant 5) is unachievable without it.

Adjacent patterns, not constituents: Notification Fanout (the unshaped sibling — same loop without the gate); Consent / Propagate Consent Revocation Downstream (legal permission, sequenced before this composition by the deployment); Audit Trail (attribution and tamper-evidence for deployments whose dispositions need sealing); Duplicate Prevention (at-most-once fanout semantics under retry — see Edge cases).


Composition logic

Composition state

The composition carries two state elements. Both are derived indexes per execution-contract.md §Composition state — read-path acceleration over constituent truth, rebuildable at any time from a constituent’s declared read surface, excluded from every action’s atomicity surface, and carrying no consistency claim of their own. Nothing here is extraction-pending: every fact in both elements is reconstructible through the named rebuild procedures below.

Both rebuild procedures run through Event Log’s declared read(query) surface, whose exact query shape Event Log’s own spec leaves to implementation policy. The predicates the rebuilds need — by event type, by fanout_id, by principal_ref, by decided_at range — are therefore not guaranteed by the atom; they are supplied by the deployment-declared journal_query_capability (see Configuration), the named source every derivation below rests on.

  • delivery_count_index — map from principal_ref to the (decided_at, channels) of that principal’s recent delivery dispositions, consulted by the frequency-cap evaluation (the channels ride along so a deployment whose frequency_limit_interpretation declares channel-scoped caps can partition the same entry by channel — see Edge cases, Per-channel frequency caps). Classification: derived index. Derivation: the Event Log store, through its declared read(query) surface under the declared journal_query_capability. Rebuild procedure: EventLog.read(query: fanout.created events where principal_ref = P and decided_at within the deployment-interpreted frequency window) — every fact in the index is a fanout.created event’s (principal_ref, decided_at, channels) triple, so the index regenerates from empty by one query per principal (or one time-ranged query for all principals). Populated by: each committed delivery disposition (action wiring step 6). Read by: the Shaping Disposition evaluation (step 5). Removed by: timestamps aging out of the interpreted window. A missing or lost entry is a rebuild trigger, not data loss. Deliberately outside the atomicity surface: the index’s population is evidence the truth-bearing writes committed, never a peer write — which is exactly why frequency-cap safety (Invariant 4) is stated in conditional form rather than as an unconditional bound; the gap between reading the count and committing the delivery is a time-of-check-to-time-of-use race, the load-bearing hazard the formal layer verifies (see the Ledger’s formal: line).
  • fanout_dispositions — map from fanout_id to the invocation’s disposition lists, consulted by audit queries. Classification: derived index. Derivation: the Event Log store, through read(query) under the declared journal_query_capability. Rebuild procedure: EventLog.read(query: events where fanout_id = F) — the fanout.initiated event carries the invocation’s scope, payload digest, and fired_at; the per-subscriber fanout.created / fanout.suppressed / fanout.create-failed events carry the dispositions; the map is their grouping by fanout_id. Populated by: the same appends. Read by: Generation acceptance traversals and operator queries. Removed by: cache eviction at the deployment’s discretion (any horizon, any policy) — every evicted fact regenerates from the journal, so the index is bounded by operational choice, not by the spec.

The Subscription store (who is subscribed), the Notification store (delivery records and outcomes), the Message Preference store (preference records and their lifecycle), and the Event Log store (the disposition journal) are owned by their constituent instances. The composition duplicates none of them.

Configuration

Deployment-settable knobs. The first three are interpretation rules — Message Preference stores its shaping values opaque and names the composing fanout pattern as interpreter, so the deployment must declare, per instance, how each opaque value is read. Each interpretation rule must be a pure, deterministic, total function of the stored value (plus the injected now where time-dependent): the same stored value and the same inputs always yield the same interpretation, or disposition replayability (Invariant 7) fails. The interpretation configuration is versioned, never mutated: any change to an interpretation rule, the default shape, the statutory window, or a policy knob produces a new config_version; each invocation journals the version in force in its fanout.initiated entry, and replay evaluates under the journaled version, so a legitimate configuration change between disposition and replay can never masquerade as a gate defect. The deployment retains every version’s rules for its audit horizon — the same deployment-owned, append-only discipline Message Preference’s instance configuration records establish for its channel set (Message Preference, Store instance model). Undeclared interpretations fail closed: when the gate’s precedence walk reaches the rule that would consult a field whose interpretation the deployment has not declared — or reaches the no-record deliver path with no declared channel_interpretation / default shape to deliver by — the subscriber’s disposition is failed with cause interpretation-undeclared (Action wiring step 5; raised at the consuming rule, so a higher-precedence suppression such as suspended wins first): never a silent deliver past an unevaluable value, and never a suppression reason that claims an evaluation that did not happen. “Field” here means the four interpreted preference fields (channel_preferences, frequency_limit, quiet_hours, format); metadata is stored opaque, never interpreted by this composition, and never triggers the rule. A deployment whose preference records can carry a field must declare that field’s interpretation; running without it is a standing configuration nonconformance the failed dispositions make visible.

  • quiet_hours_interpretation — the rule mapping a stored quiet_hours value to a predicate over the injected now: is this instant inside the principal’s quiet window? Must resolve the window in the recipient-local time the stored value declares (e.g., the timezone field in {start: "22:00", end: "07:00", timezone: "America/Los_Angeles"}). Regulated deployments under the TCPA must configure the interpretation so that, at minimum, the windows the regulation presumes (no calls/texts before 8am or after 9pm recipient-local time, 47 CFR — Code of Federal Regulations — §64.1200(c)(1)) evaluate as quiet when the principal’s stored value declares them. No default; a record carrying quiet_hours under an undeclared interpretation fails closed per the preamble rule.
  • frequency_limit_interpretation — the rule mapping a stored frequency_limit value to one or more (window, cap) pairs (e.g., {per_day: 5} → at most 5 deliveries per rolling 24 hours). Declares whether windows are rolling or calendar-aligned, and which timezone anchors calendar alignment. No default; fails closed per the preamble rule.
  • channel_interpretation — the rule mapping a stored channel_preferences map to the deliverable channel set for a disposition (e.g., values "opt-out" exclude the channel; everything else includes it), and mapping a stored format value to the envelope’s format field — including the format applied when a record supplies channel_preferences but no format (absence is no-preference, per Message Preference’s Behavior; this rule supplies the deployment default for the absent dimension). Includes the default shape — the channel set and format used wherever the deliver path has no stated channels: the deliver-unshaped path (see no_record_policy) and any record that carries preference fields but no channel_preferences (step 5.v). The declared default shape must have a non-empty channel set: an empty default would make every no-record deliver verdict collapse into suppress(channel-opt-out) — a suppression whose recorded reason names an opt-out no one stated; a deployment that wants no-record principals suppressed declares no_record_policy = suppress and gets the honest no-record reason instead. No default interpretation; the default shape is also deployment-declared.
  • no_record_policydeliver-unshaped | suppress. What the disposition is when Message Preference.current_for(principal_ref) returns none: deliver using the declared default shape, or suppress with reason no-record. deliver-unshaped is well-formed only when channel_interpretation’s default shape is declared; set without one, the no-record deliver path fails closed as fail(interpretation-undeclared) (rule v) rather than delivering silently. This is the fanout-on-no-record policy Message Preference’s Generation acceptance Check 6 requires the deployment to disclose; this composition surfaces it as a named knob so the disclosure is configuration, not narration. Default: deliver-unshaped (a principal who never stated preferences has not opted out; deployments whose regulatory posture requires opt-in suppress instead).
  • cap_policydrop | hold. Whether a frequency-cap suppression is terminal for this invocation (drop) or marked retry-eligible (hold) in the suppression event, signaling the deployment’s re-fanout machinery that the principal may be retried after the window relents. The composition itself never schedules a retry — hold is a classification on the record, not a queue (see Edge cases). Default: drop.
  • quiet_window_policydrop | hold. Same two values, same semantics, for quiet-window suppressions. Default: hold (a quiet-window suppression is the canonical “deliver later” case; the marking lets the deployment’s scheduler find it).
  • cap_serializationserialized-per-principal | best-effort. Declares whether the host serializes the frequency-cap evaluation and the delivery commit per principal — i.e., whether two concurrent invocations evaluating the same principal’s cap are forced to observe each other’s committed deliveries. Under serialized-per-principal, Invariant 4’s bound is unconditional within the declared window — which additionally obligates the implementation to evaluate against a count consistent with the journal at commit time: a missing or stale delivery_count_index entry must be rebuilt from the journal before the evaluation, or the declared capability is violated. This is conformant with the derived-index rule, not an exception to it: the index remains a rebuildable projection making no consistency claim of its own — the serialized evaluation path simply performs the rebuild inside the serialization boundary, which is exactly the guarantee a rebuild can honor. This per-principal serialization governs the cap path wherever it runs — the original Fanout Shaped pass and every Redispose re-evaluation alike; the per-disposition section on (fanout_id, principal_ref) (Configuration; held by Redispose step 3) secures Invariant 9’s forward bound and does not subsume it, so two concurrent cap evaluations for the same principal — whichever action raised them — must still serialize against each other under this knob. Under best-effort, concurrent invocations can each read count = cap − 1 and both deliver — the bound holds at quiescence with overshoot bounded by the number of concurrent invocations, and the overshoot is detectable from the Event Log (see Invariant 4). Default: none — the deployment must declare one; regulated deployments whose frequency caps carry legal force (TCPA frequency restrictions) must declare serialized-per-principal.
  • journal_query_capability — the deployment’s declared guarantee that its Event Log instance’s read(query) supports the predicates this composition’s derivations and audits require: filter by event type, by fanout_id, by principal_ref, and by decided_at / wall-time range. Event Log’s own spec leaves the query shape to implementation policy — its Edge cases assign payload-field lookup to a separate Reverse Index pattern (forthcoming) — so this capability cannot be inherited from the atom; the deployment declares it directly, and every rebuild procedure, acceptance check, adversarial-scenario query, and the action surface itself (Redispose steps 2–3 read the journal by fanout_id and by latest-event-per-principal at runtime) rests on it. When the Reverse Index pattern lands, it becomes the natural constituent home for this capability and the declaration thins to naming that peer. Required; no default; verification is an externally-clearable check (the declared predicates demonstrably work against the deployed instance).
  • payload_digest_function — the digest function applied to payload_content for the fanout.initiated entry and the consistency check (Generation acceptance Check 6). Deterministic; collision resistance appropriate to the deployment’s audit stakes. Required; no default.
  • reconciliation_window — the bound within which the deployment’s reconciliation resolves journal gaps and best-effort cap overshoots (the liveness arms of the invocation→dispositions relation and Invariant 4). A duration; required — the relation’s liveness arm binds every deployment, not only best-effort ones (a crash can gap any deployment’s journal). The setting rule: short enough that the liveness claims mean something to the deployment’s audit regime — for regulated deployments, no longer than the reporting or response period of the obligation the journal evidences; a window so long it never binds in practice is a vacuous declaration an auditor should flag. The inequality that makes the window meetable is invocation_duration_bound + notification_create_latency + reconciliation_cadence + disposition_write_latency < reconciliation_window, strictly — a gap created at t (a fanout.initiated whose loop died) is invisible to Reconcile Gaps until t + invocation_duration_bound + notification_create_latency, the next run is at most a cadence later, and the closing write lands a latency after that; invoked within the window on its own is satisfied by a deployment that breaches on every gap. The four terms are checked at instance start, and a deployment whose knobs fail the inequality refuses to start (§Liveness is arithmetic). Disclosed with the other knobs.
  • reconciliation_cadence — the interval at which the deployment’s scheduler runs Reconcile Gaps and Reconcile Overshoots, in addition to a mandatory run at restart. A duration; required — the third term of the reconciliation_window inequality.
  • notification_create_latency — the deployment’s disclosed bound on one Notification.create round-trip: the interval between the call being issued and the record being landed and visible to pending_for. A create issued at the lease’s last instant lands inside this latency, so the leg’s lower edge is invocation_duration_bound + notification_create_latency — below it, a pending_for read can precede a create that is still landing, and the leg would redispose beside it. A duration; required — the second term of the reconciliation_window inequality.
  • disposition_write_latency — the deployment’s disclosed bound on one closing write landing: a Redispose disposition, an adoption fanout.created, an abandonment record. A duration; required — the fourth term of the inequality, declared rather than observed so that the start check can read it.
  • max_query_latency — the deployment’s disclosed bound on the interval between the seam’s injection of now (which stamps fired_at) and step 3’s Subscription.subscribers_for returning. fired_at is a lower bound on the instant the query ran, never the instant itself; Generation acceptance Check 1 widens its reconstruction window by this latency, so a cancellation landing inside it reads as ambiguous rather than as a fidelity finding. A duration; required.
  • abandonment_principals_cap — the most principals one fanout.abandoned record names. A residue larger than the cap is written as several records, and a principal is covered when any record names them or all. Together with the sizing rule in Primitive policies this is what keeps the largest record the composition can write — an abandonment over a whole queried set — inside the Event Log instance’s payload constraint (§An outcome is sized before the intent). An integer; required.
  • Per-disposition section — an instance capability requirement, not a knob: a per-key critical section the host supplies, keyed by (fanout_id, principal_ref). Its semantics are fixed here so that every writer reads them one way. The section is released on the holder’s return or death, and the host implements it as a lease — a section that cannot expire is not a conforming section, because the terminus below is what one writer per disposition rests on. The lease is exactly invocation_duration_bound long — measured from fired_at for a Fanout Shaped holder, from the holder’s own injected now for a Redispose or Reconcile Gaps holder — and its expiry is the holder’s terminus: a Fanout Shaped whose lease has expired commits no further disposition and returns its remaining subscribers as yielded; a Redispose whose lease has expired writes nothing further and returns rejected(yielded); a Reconcile Gaps whose lease has expired leaves the principal to its next run. Every disposition write is issued and landed only while the section is heldFanout Shaped step 6’s pair, Redispose step 4’s commit, Reconcile Gaps’s adoption, redispose, and abandonment alike. The section is reentrant for its holder and for nobody else: Reconcile Gaps takes it per principal and calls Redispose as the holder — one hold, one lease, the leg’s now; a Redispose that arrives while a foreign holder holds the pair (a live Fanout Shaped, or a leg) does not wait — it returns rejected(yielded) and writes nothing. And a lost section is re-taken before any pre-check: a holder that finds itself without the section takes it again before it reads the latest disposition event or pending_for, and re-reads that pre-check under it. Default: none — an instance that cannot supply the section does not start, because one writer per disposition (§A compensator is exclusive) rests on it. This section is the (fanout_id, principal_ref) axis of Edge cases — Cross-store consistency under partial failure; the cap_serialization per-principal serialization is the other axis, and the two are independent.
  • orphan_lookup — an optional instance capability requirement: a deployment-supplied lookup from (fanout_id, principal_ref) to every Notification record whose envelope carries that fanout_id, in any status. Notification’s audit surface declares every record queryable on stored fields, but its runtime action surface exposes only status_of and pending_for, so a lookup by envelope field is the deployment’s to supply and this composition may not call it the constituent’s read; when the Reverse Index pattern lands it becomes the lookup’s constituent home. With it declared, Reconcile Gaps adopts a deliver-pair orphan in any status. Without it, the leg adopts through pending_for alone, and an orphan the transport dispatched before the leg ran stays a Notification without a journal pair — a disclosed residual (Edge cases, Cross-store consistency under partial failure); regulated deployments declare it. Default: not supplied.
  • invocation_duration_bound — the longest a single Fanout Shaped invocation may take between its first committed write (step 4’s fanout.initiated, stamped fired_at) and its last. The one now is injected once at the seam (Clock semantics), so a long loop evaluates its last subscriber’s quiet/statutory window against a now read at invocation start; Invariant 3’s quiet-window safety for that late-loop subscriber rests on this bound being small enough that the injected now resolves the same quiet/statutory window when the subscriber is evaluated as it did at fired_at. It does three further jobs, each named where it happens: it is, plus notification_create_latency, the lower edge of Reconcile Gaps, which examines no invocation whose fired_at is younger than the sum; it is the length of the per-disposition lease where the host implements the per-disposition section as a lease (below); and it is the invocation’s terminus — at fired_at + invocation_duration_bound a Fanout Shaped still in its loop commits no further disposition and returns its undisposed subscribers as yielded (Action wiring step 5), which are Reconcile Gaps’s from then on. A duration; required for every deployment — the leg’s edge and the terminus need a duration, so unbounded is not a lawful value; its setting rule covers the create round-trip — a create is issued only while the section is held, and it lands within notification_create_latency of its issue, so the bound is set from the loop’s worst case with that round-trip inside it; a deployment carrying Invariant 3’s regulated (TCPA) arm sets it no larger than the safety margin of the narrowest quiet or statutory window it must honor, and a deployment with no statutorily time-restricted channel in scope (the same posture as omitting statutory_quiet_window) may set it generously, disclosing so. The mitigation where the bound would otherwise be material is a transport-layer re-check before dispatch (Clock semantics). Disclosed with the other knobs.
  • statutory_quiet_window — optional: a deployment-declared (window, channel set) pair evaluated for every disposition regardless of the principal’s stored preferences — the structural home for quiet windows the law imposes rather than the principal states. The TCPA’s 8am–9pm recipient-local presumption is not preference-conditional, so the single setting rule is: required for any deployment whose deliveries on a statutorily time-restricted channel fall within the restriction’s scope — for the TCPA, solicitations on voice or SMS; a deployment using those channels solely for messages outside the restriction’s scope (e.g., genuinely transactional alerts) may omit the knob, and that omission is itself a disclosed legal posture, not a silent default. Step 5.v excludes the declared channels while the injected now is inside the window, and an envelope emptied by the exclusion suppresses as quiet-window. Resolved recipient-locally under the same clock capability as quiet_hours_interpretation.
  • clock_tolerance — two declared components, because two distinct clock disciplines are consumed: (a) the cross-store skew bound — the maximum divergence expected between the host’s injected now (which stamps fired_at / decided_at) and the constituent stores’ own write clocks (Subscription’s subscribed_at / cancelled_at, Message Preference’s set_at / deleted_at) — consumed by Generation acceptance Checks 1 and 3; and (b) the accounting-clock regression bound — the host clock’s own maximum backward step across invocations — consumed by Invariant 4’s window arithmetic. A deployment may declare one number for both only when it genuinely bounds both; a zero-skew deployment with a 2-second time-synchronization step has a zero (a) and a nonzero (b). Required; mirrors Message Preference’s clock-tolerance disclosure discipline.

Primitive policies

Composition-boundary validation for both actions’ string-typed inputs:

  • event_scope — non-null, non-empty string (rejection: invalid-request). Opaque; no normalization, no case folding; exact-match when passed to Subscription.subscribers_for. Validated before any id is generated or constituent called. The composition imposes no semantic length cap, but the composing layer must bound event_scope — and the deployment must size the Event Log instance’s payload constraint (default 64 KB) for the fanout.initiated entry at its maximum fanout scope, since the entry journals the full queried list — so that the invocation entry fits; an oversized entry surfaces as journal-rejected at step 4, a definitive rejection, not a retryable outage. The sizing covers the largest record the composition can write, not only the intent: a disposition event’s evaluation_inputs are bounded by the declared interpretation’s (window, cap) pair count and the instance’s declared channel set, and a fanout.abandoned record by abandonment_principals_cap (Configuration) — both are sized against the same payload constraint at instance start, because an outcome the cap refuses after the create has landed is a deliver-pair orphan, not a clean rejection (§An outcome is sized before the intent).
  • payload_content — non-null (rejection: invalid-request). Opaque; carried unchanged into every created Notification’s envelope. Schema validation, size limits, and content restrictions belong to the composing layer before invoking the action. Note the envelope distinction: payload_content is the content element; the composition wraps it per recipient in an envelope {content, channels, format} whose channel and format fields vary by disposition (see Action wiring step 6 and Invariant 8).
  • fanout_id (as Redispose input) — non-null, non-empty (rejection: invalid-request); opaque, byte-exact comparison against journaled ids; a non-empty value matching no fanout.initiated entry is not-known, not invalid-request (Redispose steps 1–2). principal_ref and payload_content carry the same rules on Redispose as on Fanout Shaped.
  • principal_ref / subscriber_ref equality — the composition equates Subscription’s subscriber_ref and Message Preference’s principal_ref byte-exactly, with no normalization. Message Preference declares exactly this posture for principal_ref (exact equality, no canonicalization); Subscription stores and matches subscriber_ref as an opaque value it never parses or normalizes but states equality semantics explicitly only for event_scope — so the byte-exact rule here is this composition’s own declared policy, conservative against both constituents. The deployment must therefore register subscriptions and preference records under one canonical reference per person — the unified principal namespace obligation. A deployment that subscribes User-42 but records preferences under user-42 gets none from current_for and the no_record_policy path, silently unshaped or suppressed. The namespace coincidence is a deployment-configuration capability the records alone cannot prove; it is routed to an externally-clearable check (Generation acceptance).

Action wiring

The composition exposes three surfaces: the fanout itself; Redispose — the journaled, gate-evaluated retry surface for a single principal of a prior invocation; and a composition-introduced reconciliation surface (Reconcile Gaps / Reconcile Overshoots, defined after Redispose) that owns the journal-gap and cap-overshoot repair writes.

fanout_shaped(event_scope, payload_content) → {fanout_id, created: [(principal_ref, notification_id), ...], failed: [(principal_ref, cause), ...], suppressed: [(principal_ref, reason, preference_id | none), ...], yielded: [principal_ref, ...]} | rejected(invalid-request | subscribers-unavailable | journal-rejected)

  1. Validate inputs. event_scope non-empty; payload_content non-null. Either failure → rejected(invalid-request). No id is generated; no constituent is called.
  2. Generate fanout_id — opaque, system-generated, invocation-unique (a direct effect: entropy.generate()). The correlation handle binding the invocation’s journal entries together. Unlike Notification Fanout — where fanout_id is ephemeral unless the caller composes Event Log — here the id is always durable: step 4 journals it.
  3. Query the subscriber set: Subscription.subscribers_for(event_scope). If the subscription store is unavailable (infrastructure failure at the read), return rejected(subscribers-unavailable); nothing has been written; the generated fanout_id is discarded.
  4. Journal the invocation: EventLog.append({type: "fanout.initiated", fanout_id, event_scope, queried: [subscriber_ref, ...], config_version, payload_digest, fired_at}), where queried is the exact subscriber list step 3 returned (journaled so that coverage accounting and crash-gap retry read the invocation’s true scope from the journal — never from a runtime reconstruction against Subscription’s stores, whose declared runtime queries are current-state only), config_version identifies the deployment’s interpretation-configuration version in force (Configuration preamble), fired_at is the host-injected now of the invocation, and payload_digest is the declared payload_digest_function applied to payload_content (the digest, not the content, is journaled — see Edge cases, Payload data in the journal). If the append is rejected (invalid-payload storage-failure), return rejected(journal-rejected): the composition does not run an unjournaled fanout, because every downstream guarantee (Invariants 1, 4, 5, 7) reads from the journal. The single rejection name covers both append outcomes deliberately: both share the same immediate consequence — the invocation must not proceed unjournaled — and the composition keeps its rejection taxonomy at the invocation level rather than re-exporting Event Log’s two codes. The cost is that the caller distinguishes the retryable case (storage-failure — retry succeeds when the store recovers) from the definitive one (invalid-payload — an oversized event_scope, per Primitive policies — rejects identically on retry) only by retrying; deployments that need the distinction at first rejection read it from their Event Log instance’s own telemetry. No Notification record has been created. If the queried subscriber set is empty, the append carries the invocation anyway and the action returns {fanout_id, created: [], failed: [], suppressed: [], yielded: []} — a valid, journaled, empty fanout.
  5. For each subscriber_ref in the queried set, take the per-disposition section, then evaluate the shaping gate. First take the per-disposition section on (fanout_id, subscriber_ref) (Configuration, Per-disposition section) and re-read the latest disposition event for the pair under it: where one exists — a Reconcile Gaps run or a caller’s Redispose has already landed this subscriber’s outcome — adopt it into the matching returned list and go to the next subscriber, writing nothing; two writers never land one disposition (§A compensator is exclusive). The invocation samples no second clock; its terminus is the section’s expiry — a take refused, or a hold lost, because fired_at + invocation_duration_bound has passed — at which it commits nothing further and returns this and every remaining subscriber in yielded, Reconcile Gaps’s from then on. Otherwise read Message Preference.current_for(subscriber_ref)record | none; if that read itself fails, the pre-gate guard fires before the gate runs (below). Otherwise assemble the gate’s inputs and evaluate Shaping Disposition — the composition-introduced pure gate whose signature, inputs, and purity contract are defined in §The load-bearing wiring decision (Mechanism). Its inputs are: the record (or its absence); the injected now; the principal’s recent delivery history from delivery_count_index, supplied as the (decided_at, channels) set or — when the index misses and the cold-start rebuild read fails — an explicit unavailable marker (consulted only if rule iv is reached); and the Configuration’s interpretation rules, each carrying per field whether its interpretation is declared. The gate renders one verdict by the declared precedence order in the table below: top-to-bottom first-match, evaluation stopping at the first suppression, the recorded reason being the first rule that fired (Invariant 2). A rule whose input is absent is skipped — a record with no quiet_hours evaluates no quiet window (absence is no-preference, per Message Preference’s Behavior).
# Rule Fires when Undeclared / unavailable input → Verdict
i Suspended the in-effect record’s status is suspended — (consults no count or interpretation) suppress(suspended) — even though the Subscription is Active; Message Preference Invariant 6 keeps the paused values intact, and this gate is what makes the pause operative
ii No Record current_for returned none deliver-unshaped set with no declared default shape → falls to rule v’s fail-closed (a surfaced configuration nonconformance, never a silent deliver) per no_record_policy: suppress(no-record), or fall to rule v with the declared default shape — where the statutory_quiet_window exclusion still applies, so a no-record principal can still suppress(quiet-window)
iii Quiet Window the record carries quiet_hours and quiet_hours_interpretation(quiet_hours, now) is inside quiet_hours_interpretation undeclared → fail(interpretation-undeclared) suppress(quiet-window)
iv Frequency Cap the record carries frequency_limit and, for some interpreted (window, cap) pair (channel-scoped pairs count only history entries naming that channel), the in-window count of delivery-history entries ≥ cap frequency_limit_interpretation undeclared → fail(interpretation-undeclared); delivery history is the unavailable marker → fail(accounting-unreadable) suppress(frequency-cap)
v Channel selection reached when no earlier rule has fired channel_interpretation (or, on a path that needs it, the default shape) undeclared → fail(interpretation-undeclared) channel_interpretation maps channel_preferences — or the declared default shape where the deliver path has no stated channels — to the channel set and format; channels inside a declared statutory_quiet_window containing now are then excluded; non-empty → deliver(channels, format), empty → suppress (reason in prose)

The two in-gate fail verdicts — fail(interpretation-undeclared) and fail(accounting-unreadable) — are raised at the precedence rule that first consults the missing input (the Undeclared / unavailable input column; the Configuration preamble’s fail-closed rule, applied per rule), and only if no earlier rule has already suppressed. A higher-precedence suppression that does not depend on that input therefore wins first: a Suspended record suppresses as suspended without consulting any count or interpretation, even during an Event Log outage. No fail-closed cause ever yields a deliver verdict.

Three semantics a cell cannot hold stay in prose beside the table:

  • The pre-gate guard. A failed Message Preference read — an infrastructure failure, distinct from a successful read returning none — makes the subscriber’s disposition failed with cause Preference Unreadable, raised by the orchestration before Shaping Disposition is invoked: the record gates every rule, so a failed read leaves nothing to evaluate. It is the orchestration’s guard, not a gate verdict — preference-unreadable is absent from the gate’s codomain, it never routes through no_record_policy, and a preference-store outage degrades to a named failure, never a silent unshaped deliver to a suspended or quiet-houred principal.
  • Rule v’s empty-set disambiguation. When channel selection yields an empty deliverable set, the recorded reason names the cause: Channel Opt Out if preference values emptied it, Quiet Window if the declared statutory_quiet_window exclusion emptied it (the exclusion is recorded in evaluation_inputs) — two reasons from one row. The default shape supplies the channel dimension wherever the deliver path has no stated channels: the no-record deliver path, and a record carrying other preference fields but no channel_preferences (absence is no-preference).
  • The observation anchor. Every verdict is against the record as the gate observed it at evaluation time — the same anchor Invariants 2 and 3 carry. A set or suspend committing after the read is the named staleness window (Edge cases — Message Preference staleness within an invocation), not a violation.

The precedence order is fixed by this spec, not configurable: a deployment that re-orders it changes which reason gets recorded, and cross-deployment audit then cannot interpret suppression events uniformly.

  1. Commit the disposition.
    • Deliver verdict: call Notification.create(subscriber_ref, {content: payload_content, channels, format, fanout_id}) — the envelope carries the invocation’s id so that a record whose journal half never lands is still pairable to its invocation — then EventLog.append({type: "fanout.created", fanout_id, principal_ref, notification_id, channels, format, preference_id | none, evaluation_inputs, decided_at})Evaluation Inputs here records what the gate observed in rendering the deliver verdict: the observed record status (active none; a deliver verdict is never rendered on suspended), the injected now, where the record carried a frequency_limit the interpreted (window, cap) pairs with the in-window count the gate computed for each, and — where a statutory_quiet_window is declared — the statutory-exclusion evaluation step 5.v performed (the window and the channels it excluded, possibly none). The observed status must be recorded because it is the one field Message Preference’s Invariant 1 leaves mutable — a replay reading the record later sees its current status (perhaps deleted after supersession), so the status the gate saw lives in the event, not in the record. Recording the observation on the fanout.created (deliver-verdict) events, not only suppressions, is what makes deliver verdicts replayable (Replay semantics) and makes a best-effort overshoot diagnosable: the event shows the count the gate actually saw, which a post-hoc recount of the journal cannot recover once concurrent commits have landed. These two writes are the disposition’s truth-bearing pair, made in that order while the per-disposition section is held. They do not commit together or not at all, and the spec does not claim it: the append is un-withdrawable once landed (Event Log Invariant 1) and so is the create (Notification Invariant 9 — never deleted), so no host transaction can enlist either, and rollback is never required across a write the constituent declares irreversible (execution-contract.md §Composition model; §Durability boundaries). The order carries safety: the append needs the notification_id the create returns, so the journal half is last and no fanout.created ever names a record that does not exist. The reachable partial is the other way round — the create landed and the append did not (refused, or the process died between them): a Pending Notification record with no journal pair, its envelope carrying this fanout_id. That record is the deliver-pair orphan, not evidence of bypass, and it is Reconcile Gaps’s to adopt under recovery: true through the declared Notification.pending_for read (Edge cases — Cross-store consistency under partial failure). The invocation may re-attempt the append while it still holds the section; when the section expires or the call returns, the orphan is the leg’s and the subscriber is returned in yielded — the invocation neither retries past its bound nor appends fanout.create-failed beside a live record. Where the create itself is refused (Notification’s invalid-request, an infrastructure failure at the create — handled at this boundary exactly as Notification Fanout handles them), nothing exists and the subscriber’s disposition becomes failed: append {type: "fanout.create-failed", fanout_id, principal_ref, cause, preference_id | none, decided_at} and add (principal_ref, cause: create-failed) to failed. The gate’s fail-closed outcomes land the same way, with their causes: preference-unreadable (raised before the gate runs) and the gate’s own fail(accounting-unreadable | interpretation-undeclared) verdicts (each raised at the consuming precedence rule, per step 5). If even the failure append is rejected, the subscriber is still added to failed in the returned result, and the journal’s incompleteness for this invocation is detectable as a coverage gap against fanout.initiated (see Invariant 1’s at-quiescence form and Edge cases, Crash and journal-gap reconciliation). The delivery_count_index entry for the principal is updated after the pair commits — outside the pair, per Composition state.
    • Suppress verdict: EventLog.append({type: "fanout.suppressed", fanout_id, principal_ref, reason, preference_id | none, evaluation_inputs, retry_eligible, decided_at})evaluation_inputs records what the gate saw (the observed record statusactive suspended none — recorded because status is Message Preference’s one mutable field and replay cannot recover it from the record later; the injected now; for a cap verdict, the violated (window, cap) pair and the in-window count it computed; for a quiet-window verdict, the interpreted window). Retry Eligible is defined for every reason: quiet-window and frequency-cap carry the quiet_window_policy / cap_policy marking (hold → true, drop → false); suspended, no-record, channel-opt-out, and unsubscribed (a reason only Redispose produces — see its step 4) are always false — those suppressions relent only by the principal’s own action (a fresh set, a fresh subscribe), never by the passage of time, and even after such an action the suppression stays terminal for this invocation (Redispose’s retryability rule forecloses it); the changed state governs future invocations, which is where it takes effect. Add (principal_ref, reason, preference_id | none) to suppressed. A rejected suppression append is the same journal-gap case as above: the entry joins the returned suppressed list and the gap is reconcilable against fanout.initiated.
    • Per-subscriber dispositions are independent: a failure for one subscriber neither aborts nor delays the rest (the Notification Fanout failure-isolation discipline, inherited unchanged). Order across subscribers is unspecified; parallel execution is permitted provided each subscriber’s truth-bearing pair commits independently.
  2. Return {fanout_id, created, failed, suppressed, yielded}. The four lists are unordered. yielded is non-empty only where the invocation reached its terminus (step 5) or left a deliver-pair orphan (step 6); every principal in it is Reconcile Gaps’s, and the journal partition closes at quiescence per the invocation→dispositions relation.

redispose(fanout_id, principal_ref, payload_content) → created(notification_id) | failed(cause) | suppressed(reason, preference_id | none) | rejected(invalid-request | not-known | not-retryable | payload-mismatch | orphan-pending(notification_id) | journal-rejected(read | outcome(notification_id | none)) | yielded)

The journaled retry surface. Every recovery path in this spec — retrying a failed delivery, re-attempting a hold-marked suppression after its window relents, resolving a crash-gap subscriber — re-disposes through this action, never through a direct Notification.create: a direct create would bypass the gate (delivering inside a quiet window that opened since the original verdict, or past a cap that has since filled) and would produce a Notification record with no journal pair — past the reconciliation window with no adoption, the artifact the breach-forensics scenario treats as structural evidence of bypass (inside the window, an unjournaled record is the deliver-pair orphan the leg adopts). Redispose keeps both guarantees: the gate re-evaluates with fresh inputs, and the outcome is journaled under the original invocation’s fanout_id.

  1. Validate: payload_content non-null, principal_ref non-empty, fanout_id non-empty; otherwise rejected(invalid-request).
  2. Resolve the invocation: the (non-empty) fanout_id must resolve (via the journal) to a fanout.initiated entry; otherwise rejected(not-known). If the journal read itself fails (infrastructure), rejected(journal-rejected(read)) — nothing has been evaluated or written, and the caller may retry the whole call. The supplied payload_content must digest (under the declared payload_digest_function) to the entry’s payload_digest; otherwise rejected(payload-mismatch) — the retry is bound to the original content, which is what lets Invariant 8 extend across redispositions.
  3. Check retryability: a principal covered by a fanout.abandoned record for this fanout_id is rejected(not-retryable) regardless of their disposition state — abandonment is terminal on every path. Otherwise the latest journaled disposition event for (fanout_id, principal_ref) must be a fanout.create-failed, a fanout.suppressed with retry_eligible: true, or a fanout.created whose Notification record’s status — read via Notification.status_of(notification_id), the constituent’s declared query surface — is failed or expired (the transport-failure retry path: Notification’s own retry model is a new create per attempt, and this is where that new create routes so the re-send is re-shaped and journaled rather than bypass-shaped; whether a given transport outcome is failed or expired is the deployment’s disclosed fail-vs-expire policy, per Notification’s Generation acceptance); otherwise rejected(not-retryable) — a principal whose latest create’s Notification record is Pending or Delivered is never re-delivered under the same invocation. A principal with no disposition event under this fanout_id is admitted only on the crash-gap path: the principal must appear in the fanout.initiated entry’s journaled queried list — the invocation’s true scope, read from the journal, never reconstructed at runtime against Subscription’s stores; otherwise rejected(not-retryable) — a principal absent from the journaled queried list was never in the invocation’s scope and has no gap to repair. On this path the call also reads Notification.pending_for(principal_ref) — which returns ids — then Notification.status_of(notification_id) per returned id, and selects in the composition’s own code any record whose envelope carries this fanout_id: a deliver-pair orphan of the original loop. Finding one, the call does not adopt it — adoption is Reconcile Gaps’s alone, under its fanout.reconcile-intended record and the deployment’s service identity — and does not create beside it: it returns rejected(orphan-pending(notification_id)) and writes nothing. Section: before this check the call takes the per-disposition section on (fanout_id, principal_ref) (Configuration, Per-disposition section) and holds it through step 4’s commit — against concurrent Redispose calls, against the original invocation’s disposition commit for the same principal, and against Reconcile Gaps; the check is read under the section, never before it. The take is reentrant for Reconcile Gaps calling this action as the holder (one hold, one lease, the leg’s now); where a foreign holder holds the pair, the call does not wait — rejected(yielded), nothing written. Without it, two concurrent redisposes can both observe a retryable latest event and both deliver; Invariant 9’s forward bound rests on this declared capability. The lease is invocation_duration_bound long from this call’s own injected now; where it expires before step 4 commits, the call writes nothing further and returns rejected(yielded) — the principal is Reconcile Gaps’s (see Edge cases — Cross-store consistency under partial failure).
  4. Re-check the audience, then re-evaluate and commit: first call Subscription.subscribed(principal_ref, entry.event_scope) — the constituent’s declared point query. The original loop accepts query-time staleness because its window is bounded by one invocation’s duration; Redispose’s horizon is unbounded (a hold retry may fire days later), so the audience must be re-verified. On not-subscribed, commit fanout.suppressed with reason unsubscribed, retry_eligible: false, preference_id: none, and evaluation_inputs recording the audience re-check outcome (not-subscribed) and the injected now — what was actually evaluated was the subscription point query, not the gate, and the event says so; the principal left the audience between the invocation and the retry, journaled, never silently skipped. Otherwise run steps 5–6 of Fanout Shaped for this one principal — a fresh host-injected now, a fresh Message Preference.current_for read, the current delivery history (the cap evaluation here participates in the cap_serialization per-principal serialization exactly as the original pass does — distinct from this action’s per-(fanout_id, principal_ref) serialization above) — and commit the disposition as there, with one named deviation this step owns: any disposition-event append the Event Log rejects surfaces as rejected(journal-rejected(outcome(notification_id | none))), and the position rides the code because the create cannot be rolled back (step 6’s order and reachable partial apply here unchanged): outcome(none) where the refused append was a suppression or failure event and nothing was written, so the caller may retry; outcome(notification_id) where the deliver pair’s journal half was refused after Notification.create landed, so a live record exists, the caller must not retry, and the orphan is Reconcile Gaps’s to adopt — where the batch loop returns the subscriber in yielded, the single-principal call names the record instead (§A composition’s own rejection arm carries the retry bit). The committed event carries the original fanout_id plus redisposition: true. The per-principal disposition chain under one fanout_id is therefore append-only, and the latest event is the operative disposition (the accounting rule Check 1 and the invocation→dispositions relation use).

Reconciliation surface (composition-introduced). Two operations the deployment’s scheduler invokes on reconciliation_cadence and at restart — inside the declared reconciliation_window by the inequality Configuration states — to discharge the liveness arms of the invocation→dispositions relation and Invariant 4. They are the composition-introduced, capability-provenanced home for the three reconciliation event types — fanout.reconcile-intended, fanout.abandoned, and fanout.cap-overshoot-reconciled — and for the adoption form of fanout.created (recovery: true), which no constituent action and no Fanout Shaped / Redispose step writes — a Redispose that finds an orphan refuses (orphan-pending); only Reconcile Gaps adopts. Both read the journal through the declared journal_query_capability, compute a pure detector over the read result, and append through EventLog.append with a host-injected now; each carries no composition state — every input is the journal, and every record they write lives in Event Log, the sanctioned history store (execution-contract.md §Composition model: a composition that needs to record that a multi-step sequence occurred composes Event Log), so neither surface introduces a store, an identity model, or an invariant surface of its own. The deployment owns when to invoke them (the reconciliation_window cadence); the composition owns what they write.

reconcile_gaps(fanout_id, payload_content | none, abandon: false | true) → {repaired: [(principal_ref, adopted | created | failed | suppressed | rejected(reason))], abandoned: [principal_ref | all]} | rejected(invalid-request | not-known | payload-mismatch | too-young | journal-rejected(intent | adoption(notification_id) | abandonment)) — resolve an invocation’s journal gap. payload_content is none where the deployment no longer holds the content: the leg then performs no redispose (the digest check is skipped) and may only adopt and abandon. abandon is the deployment’s declared choice not to repair: with true the leg adopts what it can and abandons every remaining gap principal, writing the all form when the residue is the whole gap set; the choice lives in the argument, not in narration. The leg reads its own now once, host-injected at its seam. Its interval has two edges. Below: it examines no invocation whose fired_at is younger than invocation_duration_bound + notification_create_latency — such an invocation may still be in its loop, or a create it issued at the lease’s edge may still be landing, and its undisposed subscribers are work in flight, not gaps; the call returns rejected(too-young) and writes nothing (§A reconciliation is bounded at both ends). Above: it examines only invocations whose fired_at lies inside the journal’s retention horizon (External checks — Journal capability and durability); an invocation past it is reported, never repaired — its survivors are the truth-bearing class, and a fanout.initiated whose dispositions have aged out is not a gap. Between the edges, the leg as follows. Resolve the fanout.initiated entry (else not-known); where payload_content is supplied, verify it against the journaled payload_digest under the declared payload_digest_function (else payload-mismatch), since repair re-disposes through Redispose, which is content-bound. Append {type: "fanout.reconcile-intended", fanout_id, decided_at} — the leg’s own intent record, written before any repair and naming the invocation it is about to repair; the gap set it will act on is derivable from the journal at this record’s position, so the record carries a boundary rather than a list (§Recovery commits under a declared service identity). Reconstruct the queried set from the entry’s journaled queried list, and for each principal with no disposition event — the gap set, per Check 1 — take the per-disposition section on (fanout_id, principal_ref) (Configuration) and re-read the latest event under it; a principal that now carries one is skipped. Then read Notification.pending_for(principal_ref) — the constituent’s declared query, which returns ids — and Notification.status_of(notification_id) for each returned id, and select, in the composition’s own code, the records whose envelope carries this fanout_id (the filter is the composition’s, not the constituent’s read); where orphan_lookup is declared the same selection runs over every status. Exactly one such record is the deliver-pair orphan of step 6: adopt it — append {type: "fanout.created", fanout_id, principal_ref, notification_id, channels, format, preference_id: none, evaluation_inputs: unavailable(recovery), recovery: true, decided_at}, carrying the record’s own envelope channels and format and nothing the gate observed, because the gate’s observation died with the process and a recovery writes only what it can re-derive. More than one is a section breach: adopt none, and abandon the principal with reason candidates-ambiguous and the candidates named. None: the principal received nothing through this composition — with payload_content supplied and abandon: false, call redispose(fanout_id, principal_ref, payload_content) as the section’s holder (its crash-gap admission path, Redispose step 3, accepts exactly these principals; the take is reentrant for the leg). A redispose that returns rejected(yielded | journal-rejected(outcome(…)) | not-retryable) is a repaired entry rejected(reason), carried to the next run and never counted as abandoned — a journal-rejected(outcome(notification_id)) in particular has left a deliver-pair orphan the next run adopts. For any residue the leg abandons — every remaining gap principal under abandon: true (reason operator-declined, or payload-unavailable where payload_content is none; a principal whose prior run’s redispose was rejected carries redispose-rejected(reason)), and a principal whose candidate set is not a singleton (candidates-ambiguous) — append {type: "fanout.abandoned", fanout_id, principals | all, reason, candidates | none, decided_at} under the same section, reason from the closed set {payload-unavailable, operator-declined, candidates-ambiguous, redispose-rejected(reason)}, at most abandonment_principals_cap principals per record (a larger residue is several records; a principal is covered when any record names them or all) — the terminal that discharges the relation’s liveness arm for the covered principals (Redispose rejects any principal an abandonment record covers, so the terminal is enforced, not advisory). Each section is released on return or death; where the leg’s lease expires mid-principal, it writes nothing further for that principal and the next run takes it up. A rejected append surfaces as rejected(journal-rejected(intent | adoption(notification_id) | abandonment)), the position on the code: intent lands before any repair and the whole call may be retried; adoption(notification_id) lands with the orphan still unjournaled — the record stands, this run’s earlier repairs stand, and the next run adopts it; abandonment lands after this run’s adoptions and redisposes, which stand — a retry recomputes a smaller gap set and is safe.

reconcile_overshoots(principal_ref) → {recorded: [(config_version, window, cap, committed)]} | rejected(invalid-request | journal-rejected) — discharge Invariant 4’s best-effort liveness arm. Run the overshoot detector: read the principal’s fanout.created events and, under each journaled config_version’s interpreted (window, cap) pairs, flag any window whose committed count exceeds its contemporaneous cap. For each overshoot, append {type: "fanout.cap-overshoot-reconciled", principal_ref, config_version, window, cap, committed, action, decided_at} — the record Generation acceptance Check 2 reads as the reconciliation evidence. Idempotent: an overshoot already carrying a reconciliation record is not re-recorded, so a re-invocation within the window adds nothing — and the claim rests on a stated serialization: the deployment’s scheduler runs at most one Reconcile Overshoots per principal_ref at a time, a conformance requirement, and the existing-record check is re-read under it, or two concurrent runs each find none and each append. The detector examines only windows whose start lies inside the journal’s retention horizon; a window that straddles it is undercounted by construction and is reported, never recorded as an overshoot.

Replay semantics

Disposition replay — the procedure Invariant 7 and Generation acceptance Check 5 rest on: given a fanout.suppressed or fanout.created event carrying a gate verdict (an unsubscribed suppression carries Redispose’s audience re-check instead — there is no gate verdict to replay, only the recorded point-query outcome; a fanout.created carrying recovery: true carries no verdict at all — it records Reconcile Gaps’s adoption of a deliver-pair orphan, its evaluation_inputs are unavailable(recovery), and it is outside replay’s quantifier), re-evaluating the gate with the event’s recorded inputs must reproduce the recorded verdict. The inputs split by mutability. The immutable inputs come from the record: Message Preference.read(preference_id) returns the value fields (channel_preferences, frequency_limit, quiet_hours, format) exactly as the gate read them (Message Preference Invariant 1 — every field except status is immutable; none is recorded as such). The mutable and ephemeral inputs come from the event’s own evaluation_inputs: the observed record status (status is the one field Invariant 1 leaves mutable, so the gate’s observation is journaled, never re-read), the injected now, and the observed in-window counts. For replay’s entry point, the gate factors as compute-counts ∘ verdict — the cap rule is a pure function of each (count, cap) pair once counts are computed from the timestamp set — and replay re-enters at the verdict factor with the recorded counts, so it needs no reconstruction of the timestamp set a concurrent commit could have shifted. The interpretation rules come from the deployment’s declared configuration at the version the invocation’s fanout.initiated entry journaled (config_version — versions are append-only and retained for the audit horizon, per the Configuration preamble), so a configuration change after the disposition cannot shift the replay’s ground. Because the gate is pure and the interpretations are required to be pure, deterministic, and total, replay divergence is a finding against exactly one of: the journal entry, the configuration disclosure, or the implementation’s gate.

The load-bearing wiring decision

The decision the composition exists to enforce: every subscriber returned by the query receives exactly one recorded disposition — delivered, failed, or suppressed-with-reason — and the shaping gate that renders the verdict sits structurally between the subscriber query and each create, so no create can bypass it.

Principle. Message Preference shaping is only worth composing if it is unbypassable and auditable. A gate that filters the subscriber list silently produces an unanswerable audit: a subscriber absent from the created list might have been suppressed by preference, lost to a failure, or skipped by a bug — three different liabilities, indistinguishable. The trichotomy makes the three outcomes structurally distinct, and the per-disposition Event Log append makes each one provable later.

Likely objection. “Why must suppression be recorded at all? Not delivering is the absence of an action — recording every non-delivery bloats the log.” For an unregulated feed, perhaps. But the regulatory questions this composition exists to answer are precisely about non-delivery: prove you did not text this person inside their quiet window is answered by the suppression record showing the gate fired; prove you honored the opt-out is answered the same way. An absence proves nothing; a classified suppression event carrying the reason, the preference record id, and the evaluation inputs proves the gate evaluated and what it saw. The cost is one append per suppression — and the frequency-cap accounting needs the delivery events in the log anyway, so the journal is already load-bearing.

Mechanism. The gate is the composition-introduced pure function shaping_disposition(principal_ref, preference_record | none, now, recent_delivery_history | unavailable, configuration) → deliver(channels, format) | suppress(reason) | fail(accounting-unreadable | interpretation-undeclared) — evaluated once per subscriber per disposition (the original invocation’s pass, and again on each Redispose), between the query and the create (Action wiring step 5). The two fail(...) verdicts are the input-specific fail-closed outcomes: the gate raises them at the precedence rule that would consult the unavailable history or the undeclared field, so a higher-precedence suppression short-circuits before an unevaluable lower-precedence input is reached (preference-unreadable is not a gate verdict — without the record the gate is never invoked). Its delivery-history input is the principal’s recent delivery history — the delivery_count_index entry’s (decided_at, channels) pairs, or the unavailable marker — not a pre-computed count: frequency_limit_interpretation may yield several (window, cap) pairs (possibly channel-scoped), and each pair’s count is computed inside the pure function from the one history set. It is pure under the Logic Confinement Principle (execution-contract.md): now is injected by the host at the invocation’s single seam, never read inside — where now appears in the signature of this pure function or of quiet_hours_interpretation(quiet_hours, now), it is that seam-injected reading passed as a value to a pure function, not a parameter of any action (no action signature in this spec carries a clock); the history is an input from the derived index; the configuration is fixed at evaluation. Purity is what makes Invariant 7 (replayability) checkable: the recorded inputs determine the recorded verdict.

Result. The three lists partition the query result (Invariant 1); every suppression is classified and journaled (Invariant 5); the regulator’s quiet-window query, the disputing recipient’s 3am-text query, and the breach investigator’s bypass query are all answerable from the Event Log and the Message Preference store with no recourse to developer narration (Regulated adversarial scenarios; Generation acceptance).


Composition-level invariants

These invariants emerge from the composition; no constituent carries them alone. Each carries a Rests on: provenance line per pressure-testing.md §Capability provenance.

Two cross-constituent relations are declared first, per the structural-relation templates (spec-format.md §Cross-cutting authoring conventions):

  • Invocation → dispositions: one-to-many. The invocation side is mandatory — every disposition event names exactly one fanout_id that resolves to a fanout.initiated entry. The disposition side is mandatory at quiescence: every subscriber in the invocation’s queried set has at least one disposition event, and the latest event per (fanout_id, principal_ref) pair is the operative disposition (Redispose appends to the chain, never rewrites it) — as safety (no action step skips a subscriber) plus liveness (a disposition missing through partial failure — a crash mid-loop, a rejected append — is surfaced by the coverage check against fanout.initiated and, within the declared reconciliation_window, either adopted as a deliver-pair orphan, re-disposed via Redispose, or terminally discharged by a journaled fanout.abandoned record written by the composition’s Reconcile Gaps surface — see Edge cases, Crash and journal-gap reconciliation), modulo that declared compensation. Never stated as a static “always complete” — a crash between step 4 and step 7 reachably leaves a journaled invocation with fewer dispositions than subscribers, and the spec’s claim is that this state is detectable and bounded, not impossible.
  • Principal → in-effect preference record: one-to-at-most-one, optional on the record side (Message Preference Invariant 3 guarantees uniqueness when present; absence is the no_record_policy path). Read-only — this composition never writes the relation. Referential integrity: every preference_id recorded in a disposition event resolves via Message Preference.read to a record that was in effect at decided_at (reconstructible by Message Preference’s Generation acceptance Check 2).

  • Invariant 1 — Disposition trichotomy. For any Fanout Shaped invocation that returns a result (not rejected), the created, failed, suppressed, and yielded lists partition the subscriber set returned by Subscription.subscribers_for(event_scope) at the time of the query: every queried subscriber appears in exactly one list, no subscriber outside the query result appears in any, and |created| + |failed| + |suppressed| + |yielded| = |subscribers_for result|yielded being the subscribers the invocation left to Reconcile Gaps at its terminus (step 5) or as a deliver-pair orphan (step 6), empty in every invocation that finished inside invocation_duration_bound with every append landing. The journal mirror of the partition holds at quiescence per the invocation→dispositions relation above, with the latest event per (fanout_id, principal_ref) as the operative disposition once redispositions have appended. Rests on: Subscription Invariant 6 (at most one Active subscription per (subscriber_ref, event_scope) — so the queried set has no duplicate refs); action wiring steps 5–7 (the composition-introduced loop) and the declared per-disposition section (Configuration — one writer per disposition); Event Log Invariant 1 through 2 (append-only, immutable journal entries).
  • Invariant 2 — Suppression precedence over subscription. A subscriber whose disposition is suppress(suspended | quiet-window | frequency-cap | no-record | channel-opt-out) receives no Notification.create in that invocation, even though their Subscription is Active. (The closed suppression set carries a sixth reason, Unsubscribed, deliberately excluded from this invariant: it is a Redispose-only outcome of the audience re-check against a principal who has left the audience — not a gate verdict against an Active subscriber — so it falls outside this invariant’s “even though Active” scope.) Suppression reasons are rendered in the fixed precedence order of action wiring step 5; the recorded reason is the first rule that fired. The verdict is evaluated against the preference record observed at disposition-evaluation time — a suspend committing after the gate read the record does not retroactively re-render the verdict (the staleness window is named in Edge cases, mirroring Notification Fanout’s subscriber-set staleness). Rests on: Message Preference Invariant 3 (at most one in-effect record) and Invariant 7 (current_for determinism); the composition-introduced Shaping Disposition gate; Message Preference’s own declaration that the composing fanout pattern interprets its values.
  • Invariant 3 — Quiet-window safety (TCPA). Conditional invariant; antecedent inside the statement. Provided the deployment has declared quiet_hours_interpretation and supplies the recipient-local clock/timezone discipline that interpretation requires (a deployment-declared capability: the host’s injected now and the interpretation’s timezone resolution must be sound for the recipient’s locale), then: no Notification.create commits in any invocation for a principal whose preference record as observed by the gate at disposition evaluation carries quiet_hours containing the injected now under the declared interpretation; and, where the deployment declares a statutory_quiet_window, no committed envelope names a channel inside that window at the injected now (the statutory arm — the law’s window, evaluated for every principal, stored preference or none). The observation anchor is the same one Invariant 2 carries — a superseding set landing between the gate’s read and the create’s commit is the named staleness window, not a violation. The gate’s quiet-window rule precedes channel selection, and the statutory exclusion runs inside channel selection, so no deliver verdict can place a message inside either window. Rests on: Message Preference Invariant 1 (the stored quiet_hours value is immutable — what the gate read is what the principal stated); the composition-introduced gate (step 5.iii); the deployment-declared quiet_hours_interpretation (Configuration) and the recipient-local clock capability (an attestation, not a knob — declared and verified in Generation acceptance’s externally-clearable checks); and the deployment-declared invocation_duration_bound (Configuration), on which the injected now’s meaningfulness for a late-loop subscriber rests.
  • Invariant 4 — Frequency-cap safety. Conditional invariant, stated per-commit and at quiescence. Provided the deployment has declared frequency_limit_interpretation, the bound is anchored to each delivery’s own observation — at every committed delivery disposition, the in-window count the gate observed (recorded in the event’s evaluation_inputs) was strictly below every cap interpreted from the preference record observed at that disposition. The anchor matters: a principal who lowers their cap mid-window leaves a standing in-window count above the new cap with zero gate misbehavior — the bound is per-commit against the contemporaneous record, never a retrospective recount against the current one. On top of that anchor: (safety) under cap_serialization = serialized-per-principal, the observed count equals the true committed count (the gate’s read and the delivery’s commit are serialized per principal), so no two invocations can both observe headroom on the last slot and the per-window committed total respects each delivery’s contemporaneous cap; (under best-effort) two invocations may each observe count = cap − 1 and both commit (the time-of-check-to-time-of-use race), so the residual claim is: any overshoot is bounded by the number of concurrently-evaluating invocations, is diagnosable from the Event Log (each fanout.created event’s recorded observation shows the headroom the gate saw), and is detected and recorded within the declared reconciliation_window (liveness) — by the composition’s Reconcile Overshoots surface (Action wiring), which the deployment invokes alongside journal-gap repair: it runs the overshoot detector (scan delivery events per principal under each journaled config_version’s interpreted windows; any window whose committed count exceeds its contemporaneous cap is an overshoot) and appends {type: "fanout.cap-overshoot-reconciled", principal_ref, config_version, window, cap, committed, action, decided_at} — the record Check 2 reads as the reconciliation evidence. The race, not the arithmetic, is the load-bearing hazard, and it is the formal layer’s verification subject (see the Ledger’s formal: line). One further clock assumption is named rather than hidden: the window arithmetic compares decided_at values stamped by different invocations’ injections of now, so the accounting assumes the host clock is non-decreasing across invocations to within the declared clock_tolerance component (b) (the accounting-clock regression bound — distinct from the cross-store skew component) — under a larger backward step the in-window count can deflate and admit deliveries past the cap with zero gate misbehavior, the same best-effort posture as Event Log’s own Invariant 7 (sequence order authoritative, wall-time best-effort). Rests on: Event Log Invariant 1 through 4 (the delivery events are append-only, immutable, totally ordered — the count’s derivation source) under the declared journal_query_capability; the composition-introduced gate and delivery_count_index (a derived index, outside the atomicity surface by construction); the deployment-declared cap_serialization capability and clock_tolerance.
  • Invariant 5 — No silent disposition. Every suppression and every delivery failure lands as a classified Event Log event (fanout.suppressed with reason, preference_id | none, evaluation_inputs, retry_eligible; fanout.create-failed with cause, preference_id | none) bound to its invocation by fanout_id. At quiescence (per the invocation→dispositions relation), no subscriber’s non-delivery is unexplained: the journal answers why was this person not delivered to for every queried subscriber. Rests on: Event Log’s append action and Invariant 1 through 2; action wiring step 6; the Journal Rejected rejection in step 4 (the composition refuses to run unjournaled).
  • Invariant 6 — Constituent integrity. The composition never writes the Subscription store (subscribers_for and subscribed are its only Subscription calls), never writes the Message Preference store (current_for / read only), and writes the Notification store only through create (status_of and pending_for are its only other Notification calls). All Subscription invariants, all Message Preference invariants (Invariant 1 through 10, plus the Temporal property — Timestamp ordering — which Message Preference deliberately keeps outside its invariant numbering), all nine Notification invariants, and all seven Event Log invariants hold over their stores; every constituent is reached through its declared action and query surface, never by direct store access. Rests on: the constituents’ own invariants as written; the atom interface contract (execution-contract.md §The atom interface contract).
  • Invariant 7 — Disposition replayability. Shaping Disposition is a pure function of its recorded inputs: for every journaled deliver-or-suppress disposition the gate rendered (unsubscribed events record Redispose’s audience re-check, not a gate verdict — their replay is the recorded point-query outcome itself; the gate’s fail(accounting-unreadable | interpretation-undeclared) verdicts record an infrastructure-or-configuration condition rather than a reproducible deliver-or-suppress verdict, and are journaled as fanout.create-failed; a fanout.created with recovery: true records an adoption, not a verdict — all of these are outside this invariant’s quantifier), re-evaluating the gate with the event’s recorded inputs (preference record via Message Preference.read, injected now, the observed in-window counts from evaluation_inputs, declared interpretations) reproduces the recorded verdict, per Replay semantics. Rests on: Message Preference Invariant 1 (value-field immutability — the replay reads the values the gate read; the mutable status is replayed from the event’s recorded observation instead, per Replay semantics) and Invariant 9 (durability — the record is still there); Event Log Invariant 2 (the recorded inputs are immutable); the composition-introduced gate’s purity (Logic Confinement: now injected, never read inside); the deployment-declared interpretation rules’ required determinism (Configuration).
  • Invariant 8 — Payload-content consistency. Every Notification record created under a single fanout_id — by the original invocation or by any later Redispose — carries the same content element in its envelope: the payload_content whose digest the invocation’s fanout.initiated entry recorded (Redispose verifies the digest before re-evaluating, so the binding extends across redispositions). The envelope’s channels and format fields vary per recipient (that variation is the composition’s purpose); the content does not. This is Notification Fanout’s payload-consistency guarantee restated at the envelope’s content level. Rests on: action wiring steps 1 and 6 plus Redispose step 2 (one validated, digest-bound payload_content, wrapped per recipient); Notification Invariant 1 (the created record’s payload is immutable); the deployment-declared payload_digest_function.
  • Invariant 9 — At most one live notification per subscriber per invocation. Live means a Notification record in Pending or Delivered state. The queried set contains at most one entry per subscriber (Subscription Invariant 6); the loop renders one disposition per entry; only a deliver verdict creates, and it creates exactly one record. Redispose preserves the bound forward: it rejects (not-retryable) any principal whose latest disposition under the fanout_id is a fanout.created whose record is Pending or Delivered, admitting a created-again retry only when the prior record has reached transport failed or expired (each such retry is a fresh create per Notification’s own retry model, so an invocation may accumulate transport-failed records, but never two live ones); its retryability check and commit run under the per-disposition section on (fanout_id, principal_ref) (Configuration) — the section Fanout Shaped step 5 and Reconcile Gaps hold for the same pair — so no two writers land a disposition for one pair, and a deliver-pair orphan is adopted, never created beside. Rests on: Subscription Invariant 6; Notification Invariant 2 through 4 (status monotonicity — no record returns from a terminal state, so a failed/expired adjudication is stable — plus terminal exclusivity and status–timestamp match: together what makes “live” decidable from status_of); action wiring steps 5–6; Redispose step 3 and the declared per-disposition section (Configuration).
  • Invariant 10 — Invocation identity is unique and journaled. Each non-rejected invocation carries a Fanout Id no other invocation shares, journaled in fanout.initiated before any disposition commits; every disposition event for the invocation carries it. The journaling is unconditional — Event Log is a constituent, not an option, and the composition rejects rather than run unjournaled (step 4). The journal’s persistence across crashes is Event Log’s own deployment-shaped obligation: the deployment must provision the journal’s store durably for the audit horizon its regime requires, a named obligation routed to the externally-clearable disclosures. Rests on: the composition-introduced id generation (step 2 — invocation-unique by construction) and journaling (step 4); Event Log Invariant 1 through 2 (append-only, immutable entries — what is journaled stays as journaled).

Trichotomy (Invariant 1) plus no-silent-disposition (Invariant 5) give the accountable fanout property — every queried subscriber’s outcome is recorded and classified. Quiet-window safety, cap safety, and suppression precedence (Invariant 2 through 4) give the shaping is unbypassable property — the regulated suppression rules cannot be skipped on any create path. Replayability (Invariant 7) gives the defensible verdict property — every recorded verdict can be independently re-derived.


Examples

The walkthrough deployment declares channels ["email", "sms", "push"], no_record_policy = deliver-unshaped with default shape {channels: [email], format: "plain"}, cap_policy = drop, quiet_window_policy = hold, cap_serialization = serialized-per-principal, frequency_limit_interpretation reading {per_day: N} as a rolling 24-hour cap, quiet_hours_interpretation resolving stored windows in the record’s own timezone, a statutory_quiet_window of 21:00–08:00 recipient-local on {sms} (its SMS sends include solicitations), a journal_query_capability covering the four required predicates, a payload_digest_function, a one-hour reconciliation_window, a five-second invocation_duration_bound, a one-second notification_create_latency, a 500ms clock_tolerance declared for both components (the deployment attests the one bound covers cross-store skew and accounting-clock regression alike), and interpretation-configuration version cfg_v3 in force throughout.

Walkthrough — one invocation, all three dispositions

Four team members subscribe to task:assigned. Their preference states at fanout time: ana — Active record pref_a {channel_preferences: {email: "preferred", sms: "opt-out"}, format: "plain"}; ben — Suspended record pref_b (vacation pause); cho — Active record pref_c with quiet_hours: {start: "22:00", end: "07:00", timezone: "Asia/Tokyo"} — and it is 23:10 in Tokyo; dia — no record.

fanout_shaped("task:assigned", {task_id: t7, assigned_by: manager_m}):

  • Step 2–4: fanout_id = fx_01; subscribers_for("task:assigned") → [ana, ben, cho, dia]; fanout.initiated journaled with queried: [ana, ben, cho, dia], config_version: cfg_v3, and fired_at.
  • ana: record Active; no quiet hours; no cap; channel interpretation → {channels: [email], format: "plain"}Notification.create(ana, {content, channels: [email], format: "plain"}) → notif_91; fanout.created journaled. → created.
  • ben: record Suspended → suppress(suspended); fanout.suppressed journaled with preference_id: pref_b. → suppressed. (Rule 1 fired; ben’s quiet hours, had he any, were never evaluated — precedence.)
  • cho: record Active; 23:10 Tokyo is inside the stored window → suppress(quiet-window); fanout.suppressed journaled with evaluation_inputs carrying the injected now and the interpreted window, retry_eligible: true (policy hold). → suppressed.
  • dia: current_for(dia) → none; policy deliver-unshaped → default shape → Notification.create(dia, {content, channels: [email], format: "plain"}) → notif_92; fanout.created journaled with preference_id: none. → created.
  • Returns {fanout_id: fx_01, created: [(ana, notif_91), (dia, notif_92)], failed: [], suppressed: [(ben, suspended, pref_b), (cho, quiet-window, pref_c)], yielded: []}.

The deployment’s scheduler later queries retry-eligible suppressions for fx_01, finds cho’s, and after 07:00 Tokyo calls redispose(fx_01, cho, payload). The audience re-check confirms cho is still subscribed (subscribed → subscribed); the gate re-evaluates with a fresh now, the quiet window no longer contains it, and the disposition commits as created(notif_93) under the same fanout_id with redisposition: true. Had cho cancelled in the interim, the retry would have committed suppressed(unsubscribed) instead — journaled, never a delivery to someone who left. The composition never schedules; the scheduler decides when, the gate decides whether.

Frequency cap firing

eli’s record carries frequency_limit: {per_day: 3}. Deliveries committed for eli on Monday at 09:00, 11:00, and 19:00 (the delivery_count_index, derived from eli’s fanout.created events). A fourth fanout fires Monday at 21:00: the gate observes three timestamps inside the rolling 24-hour window, count 3 ≥ cap 3 → suppress(frequency-cap); the journaled evaluation_inputs record {count: 3, window: rolling-24h, cap: 3} and retry_eligible: false (policy drop). No Notification record is created. A fifth fanout on Tuesday at 13:00 finds only Monday 19:00 inside the window — count 1 — and delivers.

Best-effort cap overshoot and reconciliation

A deployment that cannot serialize per principal declares cap_serialization = best-effort. Principal fred carries frequency_limit: {per_day: 2} and has one delivery in the rolling 24-hour window (count 1). Two invocations — fx_20 and fx_21 — fire for fred at nearly the same instant. Each gate independently reads the history, observes count 1 < cap 2, and commits a delivery: fx_20’s fanout.created records {count: 1, window: rolling-24h, cap: 2}; fx_21’s records the same observed {count: 1} — neither saw the other’s not-yet-committed delivery (the time-of-check-to-time-of-use race). The committed total is now 3 against a cap of 2: a one-delivery overshoot, bounded by the two concurrent invocations.

Nothing is hidden. Each fanout.created event carries the headroom its gate actually saw, so Invariant 4’s per-commit anchor still holds — each delivery was below cap at its own observation — and the overshoot is diagnosable from the journal: a post-hoc recount of fred’s window finds 3 committed deliveries under a cap of 2. Within the declared reconciliation_window, the deployment’s scheduler invokes reconcile_overshoots(fred), whose detector flags the window and appends {type: "fanout.cap-overshoot-reconciled", principal_ref: fred, config_version: cfg_v3, window: rolling-24h, cap: 2, committed: 3, action, decided_at} — the record Generation acceptance Check 2 reads as the evidence that the overshoot was detected and recorded within the window (Invariant 4’s liveness arm, discharged). A regulated deployment whose caps carry legal force would instead have declared serialized-per-principal, under which the second gate observes count 2 and suppresses frequency-cap — no overshoot is possible.

Statutory window firing — no stored quiet hours

finn holds an Active record pref_f with channel_preferences: {sms: "preferred", email: "opt-out", push: "opt-out"} and no stored quiet_hours. A fanout fires at 22:40 in finn’s locale. Rule iii is skipped (no stored window — absence is no-preference); rule v maps the record to {sms}, then the declared statutory_quiet_window (21:00–08:00 on {sms}) excludes it — the set is emptied by the statutory exclusion, so the disposition is suppress(quiet-window) with retry_eligible: true and evaluation_inputs recording the statutory window and the excluded channel. The law’s window binds finn even though finn never stated one — that is the statutory arm’s whole point. After 08:00 finn-local, the scheduler’s Redispose delivers.

Marketing newsletter — channel opt-out under CAN-SPAM

A different deployment (a marketing platform) runs the same composition with no_record_policy = suppress — its regulatory posture treats absent preferences as no permission to shape a commercial send. Subscriber gus holds an Active record pref_g with channel_preferences: {email: "opt-out", sms: "opt-out", push: "opt-out"} — a full opt-out recorded after an unsubscribe click. Subscriber hana has subscribed but never opened the preferences page: no record. A campaign fanout fires: gus’s channel interpretation yields an empty deliverable set → suppress(channel-opt-out) with preference_id: pref_g — the journaled event is the CAN-SPAM honored-opt-out evidence; hana’s current_for returns nonesuppress(no-record) with preference_id: none, per this deployment’s declared policy. Neither receives a Notification record; both non-deliveries are classified, distinct, and queryable — the difference between they said no and they never said is preserved in the records.

Rejection paths

  • fanout_shaped("", {task_id: t9})rejected(invalid-request) — nothing generated, queried, or written.
  • fanout_shaped("task:assigned", null)rejected(invalid-request).
  • Subscription store down at step 3 → rejected(subscribers-unavailable) — no journal entry, no creates.
  • Event Log rejects the fanout.initiated append at step 4 (storage-failure) → rejected(journal-rejected) — the composition refuses to run an unjournaled fanout; no Notification record exists for the invocation. The same rejection covers a definitive invalid-payload (an oversized event_scope); retry distinguishes the two.
  • redispose(fx_01, ana, payload) when ana’s latest disposition under fx_01 is fanout.created and status_of(notif_91) returns Pending or Delivered → rejected(not-retryable) — one invocation never holds two live deliveries for the same principal (the transport-failure path admits her only once notif_91 has reached failed or expired).
  • redispose(fx_07, eli, payload) whose deliver pair’s journal half is refused after the create landed → rejected(journal-rejected(outcome(notif_96))) — do not retry; the record exists and Reconcile Gaps adopts it. reconcile_gaps(fx_08, payload) on an invocation whose fired_at is five seconds old under the five-second invocation_duration_bound and one-second notification_create_latencyrejected(too-young) — nothing examined, nothing written. redispose(fx_07, eli, payload) from the scheduler while eli’s Pending record notif_96 carries fx_07 and no journal pair → rejected(orphan-pending(notif_96)) — nothing written; Reconcile Gaps adopts it.
  • redispose(fx_99, cho, payload) where no fanout.initiated entry carries fx_99rejected(not-known); redispose(fx_01, cho, altered_payload) where the digest does not match fx_01’s journaled payload_digestrejected(payload-mismatch) — the retry is bound to the original content.

Fail-closed gate — preference store outage

A fanout fires while the Message Preference store is down. For each subscriber, current_for fails at the read (an infrastructure failure, not a none): the gate renders no verdict, routes nothing through no_record_policy, and the subscriber’s disposition is failed with cause preference-unreadable — journaled, classified, retry-eligible via Redispose once the store recovers. A suspended principal and a quiet-houred principal are not delivered to unshaped during the outage; the outage degrades to named failures, never to silent unshaped delivery. The same shape covers Interpretation Undeclared (a record carries quiet_hours but the deployment never declared the interpretation) and Accounting Unreadable (a cap-carrying record whose count cannot be rebuilt because the journal read fails) — each raised at the precedence rule that would consult the missing input (rule iii and rule iv respectively), so a Suspended record carrying either still suppresses as suspended first.

Partial failure

A fanout fx_07 to [ana, eli, fay]: ana’s disposition pair commits (created); eli’s Notification.create is refused by an infrastructure failure — nothing exists for eli, fanout.create-failed is journaled with cause create-failed, eli → failed; fay’s pair commits (created). Returns all three classified. Had eli’s create landed and the journal half been refused instead, eli would have been returned in yielded with a Pending record carrying fx_07 in its envelope, and the next Reconcile Gaps run would adopt it under recovery: true rather than redispose — one delivery, journaled late, never two. The caller retries eli with redispose(fx_07, eli, payload): the gate re-evaluates (eli’s preferences may have changed, the quiet window may now apply — the retry earns delivery, it does not assume it), and the outcome lands in the journal under fx_07. A direct Notification.create(eli, envelope) is not the retry path — it would bypass the gate and produce an unjournaled Notification that breach forensics reads as bypass evidence once the reconciliation window has passed with no adoption (inside the window, an unjournaled record is a deliver-pair orphan awaiting Reconcile Gaps). A fresh Fanout Shaped (a new invocation against the current Active set) remains correct when re-fanning the whole scope is the intent — composing Duplicate Prevention if at-most-once across such retries is required.

Transport failure after a committed create: back on fx_01, ana’s notif_91 later fails in transport (the transport layer calls Notification.fail). The re-send also routes through redispose(fx_01, ana, payload): step 3’s transport-failure path admits her because status_of(notif_91) returns failed; the audience re-check and the gate run fresh (if it is now 23:30 in ana’s declared quiet hours, the re-send is suppressed — the retry earns delivery under current rules); a deliver verdict commits a new create, notif_95, as Notification’s retry model prescribes — a distinct record with its own outcome, journaled under the invocation, with notif_91 remaining in Failed as the audit record of the first attempt.

Regulated adversarial scenarios

  • Regulator audit — “show every message delivered inside a declared quiet window.” A TCPA auditor asks for all deliveries to principals whose in-effect preferences declared a quiet window containing the delivery moment. Procedure, records alone: enumerate fanout.created events in the audit period (Event Log read, time-ranged); for each, fetch the recorded preference_id via Message Preference.read (immutable, durable); where the record carries quiet_hours, evaluate the deployment’s disclosed quiet_hours_interpretation against the event’s decided_at. By Invariant 3 the result set is empty — any non-empty result is a conformance violation, and the violating event itself carries the evidence (the verdict’s inputs are journaled). The auditor separately samples fanout.suppressed(quiet-window) events and replays each verdict (Invariant 7) to confirm the gate was evaluating, not rubber-stamping.
  • Disputed delivery — “I was texted at 3am.” A principal complains of a 3am SMS. The investigator reconstructs from records: the principal’s in-effect preference record at the delivery moment (Message Preference Generation acceptance Check 2 — max set_at ≤ t within the in-effect window); the disposition — either a fanout.created event whose decided_at, channels, and preference_id show what the gate saw and decided, or no such event (the message did not come through this composition — a finding against the deployment’s delivery inventory, not this composition’s records). If the record carried no quiet_hours, the delivery was conformant with respect to stated preferences — and the declared statutory_quiet_window is then the operative question: a 3am SMS is inside any TCPA-conformant declaration, so a delivered SMS envelope at 3am indicts the deployment’s statutory-window declaration (absent, or wrongly scoped) rather than the gate; the event’s evaluation_inputs show whether a statutory exclusion was evaluated. If it carried one and the interpretation places 3am inside it, Invariant 3’s antecedent is examined: either the gate misfired (replay the verdict — Invariant 7 isolates the divergence) or the deployment’s declared clock/timezone capability was unsound (the externally-clearable disclosure names the liable layer). The composition’s records identify which.
  • Breach investigation — suppression-bypass forensics. An incident suggests deliveries bypassed suppression during a window (e.g., a deploy that skipped the gate). The investigator cross-checks, per invocation in the window: the Active subscriber set reconstructed at the fanout.initiated entry’s fired_at (Check 1’s procedure) versus the disposition events under its fanout_id (Invariant 1 — a delivery with no fanout.created event, or a reconstructed subscriber with no disposition past the reconciliation window, is structural evidence of bypass); Notification records created in the window versus fanout.created events (a Notification whose creation has no journal pair is the deliver-pair orphan while its invocation is inside the reconciliation window and unadopted; past the window with no adoption it indicates writes outside the composition); and replay of suppression verdicts near the window’s edges (Invariant 7) to confirm verdicts matched the stored preferences. The journal’s append-only total order (Event Log Invariant 1 through 4) bounds the affected invocations.

Generation acceptance

A derived implementation is acceptable when an external auditor, given the four constituent stores, can do all of the following without recourse to source code, runbooks, or developer narration.

Record-clearable checks

  • Check 1 — Reconstruct the trichotomy for any invocation. Given a fanout_id: the fanout.initiated entry yields event_scope, the journaled queried list (the invocation’s true scope), config_version, payload_digest, and fired_at. The disposition events grouped by fanout_id, taking the latest event per principal where redispositions have appended, partition the journaled queried list — every queried subscriber in exactly one of delivered / failed / suppressed (Invariant 1; a fanout.created carrying recovery: true counts as delivered), with a journaled fanout.abandoned record terminally accounting for the principals it covers. The partition is read at quiescence: an invocation younger than invocation_duration_bound + notification_create_latency + reconciliation_cadence + disposition_write_latency may still carry a gap that is work in flight, not a finding. As a cross-check on the queried list itself, the auditor reconstructs the Active set from the Subscription store’s audit surface (Subscription’s historical filter: subscribed_at ≤ t and (status = active or cancelled_at > t)) — fired_at is a lower bound on the instant the query ran, not the instant itself, so t ranges over the window [fired_at − clock_tolerance(a), fired_at + max_query_latency + clock_tolerance(a)] — and compares: divergence from every reconstruction in that window is a finding against the implementation’s query-to-journal fidelity; divergence within it is ambiguous-pending-evidence (the Message Preference Check 4 discipline applied across stores — the Subscription store’s timestamps come from its own clock, the injected now from the host’s). Each fanout.created event’s notification_id resolves via Notification.status_of to a record with matching recipient_ref.
  • Check 2 — Verify frequency-cap safety per delivery. For each fanout.created event whose evaluation_inputs carry interpreted (window, cap) pairs, confirm the recorded in-window count is strictly below each recorded cap — the per-commit bound of Invariant 4, checked against what the gate observed and the record then in effect, never against the principal’s current record (a mid-window cap change is not retroactive). Then, under serialized-per-principal, confirm serialization itself from the observations: walking each principal’s delivery events in journal order, each event’s recorded in-window count equals the count derivable from the preceding delivery events in that window — serialization means every observation matches the committed history at its commit — and each was strictly below its own contemporaneous cap (Invariant 4’s per-commit anchor, already applied above); an adopted delivery (recovery: true) carries no observation of its own and counts toward later observations only from its adoption’s journal position, because no gate could see a create whose journal half had not landed. Under best-effort, any committed overshoot must be matched by a fanout.cap-overshoot-reconciled record whose decided_at falls within the disclosed reconciliation_window of the overshooting commit (Invariant 4’s liveness arm — the detector and record shape are defined there).
  • Check 3 — Confirm every suppression and failure is classified and grounded. Every fanout.suppressed event carries a reason from the closed set {suspended, no-record, quiet-window, frequency-cap, channel-opt-out, unsubscribed} (unsubscribed only on redisposition: true events), a preference_id resolving via Message Preference.read to a record in effect at decided_at within the disclosed clock_tolerance — or none, exactly when no in-effect record was read: the gate observed status none (the no-record reason, or a no-record deliver path emptied by the statutory exclusion), or the disposition is unsubscribed, whose evaluation_inputs record the audience re-check outcome and the injected now rather than a gate observation (the re-check never reaches the gate); the recorded preference_id is authoritative for what was read, the in-effect reconstruction is the cross-check — plus evaluation_inputs and retry_eligible (the step-6 totality rule fixes it for every reason). Every fanout.create-failed event carries a cause from the closed set {create-failed, preference-unreadable, accounting-unreadable, interpretation-undeclared} (Invariant 5 covers both halves). Every fanout.created event carries evaluation_inputs — or unavailable(recovery) together with recovery: true, exactly when Reconcile Gaps adopted a deliver-pair orphan, and then a notification_id whose envelope carries the event’s own fanout_id.
  • Check 4 — Trace any fanout_id to its complete disposition set. Every disposition event’s fanout_id resolves to exactly one fanout.initiated entry; no disposition event is orphaned; no two fanout.initiated entries share an id; fanout.reconcile-intended and fanout.abandoned are reconciliation records, not dispositions — each likewise resolves to one entry, and every recovery: true event follows a fanout.reconcile-intended for its fanout_id in the journal’s total order; redisposition events carry redisposition: true and either follow an earlier event for the same (fanout_id, principal_ref) in the journal’s total order or — the crash-gap repair case — name a principal who appears in the invocation’s journaled queried list with no prior disposition event (Invariant 10; the invocation→dispositions referential integrity).
  • Check 5 — Replay any disposition verdict. For a sampled set of fanout.suppressed and fanout.created events (excluding recovery: true events, which carry no verdict), re-evaluate Shaping Disposition from the recorded inputs per Replay semantics — both event types carry evaluation_inputs — and confirm the recorded verdict reproduces (Invariant 7). This check consumes one disclosure (the interpretation rules — see externally-clearable below); everything else is records.
  • Check 6 — Confirm payload-content consistency per invocation. For any fanout_id, every created Notification’s envelope content digests (under the disclosed payload_digest_function) to the fanout.initiated entry’s payload_digest — across the original invocation and all redispositions (Invariant 8).

External checks

  • Interpretation and policy disclosures. The deployment disclosed quiet_hours_interpretation, frequency_limit_interpretation, channel_interpretation (with the default shape), no_record_policy, cap_policy, quiet_window_policy, cap_serialization, payload_digest_function, reconciliation_window with notification_create_latency, reconciliation_cadence, and disposition_write_latency (and the inequality over the four, checked at start), max_query_latency, abandonment_principals_cap, the per-disposition section, orphan_lookup (declared, or its omission disclosed with the residual it leaves), invocation_duration_bound, clock_tolerance (both declared components — see the knob), and statutory_quiet_window — declared, or its omission disclosed as the deployment’s legal posture (the knob’s setting rule). The disclosure also covers the Configuration preamble’s version-retention obligation (every config_version’s rules retained for the audit horizon, the ground Checks 2 and 5 replay against). Without these, disposition events are not uniformly interpretable across deployments.
  • Reconciliation discipline. The deployment’s scheduler invokes the composition’s Reconcile Gaps / Reconcile Overshoots surfaces (Action wiring) on reconciliation_cadence and at restart, inside the declared reconciliation_window by the inequality Configuration states; those surfaces — not the deployment’s own code, and not Fanout Shaped / Redispose — own the writes of the composition-defined fanout.abandoned and fanout.cap-overshoot-reconciled events, so the write authority is capability-provenanced to the composition layer rather than ambient. The liveness arms of the invocation→dispositions relation and Invariant 4 rest on the scheduler actually invoking the surfaces: Check 1 reads the abandonment records and Check 2 reads the overshoot records as the evidence that discharges those arms. A deployment that declares a reconciliation_window but never invokes the reconciliation surfaces leaves those liveness arms undischarged — a standing nonconformance the absence of gap-and-overshoot records makes visible.
  • Journal capability and durability; constituent-store retention. The declared journal_query_capability’s predicates (by type, fanout_id, principal_ref, time range) demonstrably work against the deployed Event Log instance, and the journal’s store is provisioned durably for the deployment’s audit horizon — the obligations every rebuild, check, and Invariant 10’s journaling claim consume. The two indexes need different durations, and conflating them would over- or under-provision: delivery_count_index’s rebuild queries only decided_at within the interpreted frequency window, so its validity has to exceed one frequency window and nothing more; fanout_dispositions is queried by fanout_id with no time bound and serves the audit checks, so its validity has to exceed the longest claim that reads it, which is the audit horizon itself. Stating both is what makes this obligation checkable rather than a general instruction to keep things. (This composition journals to a bare Event Log rather than through an Audit Trail substrate, so it inherits no purge cascade it does not control — which is why the obligation here is a provisioning choice the deployment makes outright, rather than an ordering it must satisfy against someone else’s retention policy. Methodology debt #19’s retention-horizon class surveyed this pattern as an instance; it is better read as the shape already discharged, by composing beneath the destruction rather than under it.) The Subscription and Message Preference stores are likewise retained for the same horizon (each atom routes retention lifetime to Retention Window as a deployment choice; the audit cross-checks here read cancelled subscriptions and Deleted preference records across that horizon, so the deployment’s retention choice must cover it).
  • Single-pipeline routing. Every delivery subject to the deployment’s quiet-window and frequency obligations is routed through this composition’s gate (Fanout Shaped / Redispose) — not through the unshaped sibling, not through direct Notification.create. The composition’s invariants account only for what flows through the gate; the deployment attests that nothing regulated flows around it (see Edge cases — The gate governs only what flows through it).
  • Recipient-local clock capability. The host’s injected now and the timezone resolution behind quiet_hours_interpretation are sound for recipient locales — Invariant 3’s antecedent. The records show what the gate evaluated; whether the clock told the truth about recipient-local time is the deployment’s attestation.
  • Unified principal namespace. Subscription’s subscriber_ref and Message Preference’s principal_ref denote the same person under byte-exact equality — the namespace obligation from Primitive policies. The records alone cannot prove two opaque references co-refer; the deployment attests the canonicalization discipline.
  • Payload retention for recovery. The deployment retains payload_content keyed by fanout_id for its retry horizon (Edge cases — Payload data in the journal), since Redispose requires the digest-matching content and the journal carries only the digest.
  • Peer-pattern wiring. Whether Consent (legal permission, sequenced before invocation), Audit Trail (attribution and sealing), and Duplicate Prevention (at-most-once fanout under retry) are wired, and with what configuration.

Non-goals and edge cases

  • Legal permission is sequenced before this composition. Whether the system may communicate with the principal at all — consent under GDPR (the EU General Data Protection Regulation), prior express consent under the TCPA, opt-in regimes generally — is the Consent pattern’s question, evaluated by the deployment before Fanout Shaped is invoked (or as a gate wrapping it). A deliver verdict here is not legal permission; Message Preference’s own spec states a preference record never overrides the legal-permission answer. A deployment that invokes this composition without sequencing its consent gate has made a sequencing error at the composing layer, not a conformance error here. Propagate Consent Revocation Downstream is the peer that operationalizes the consent side; its Edge cases name this boundary from the other shore.
  • Holding is a classification, not a queue. cap_policy = hold and quiet_window_policy = hold mark suppression events retry-eligible; they do not defer, schedule, or queue anything. The composition is a stateless interpreter of its invocation graph — a deferred-delivery queue would be persistent truth no constituent owns. The deployment’s scheduler reads retry-eligible suppressions from the journal and re-invokes; each re-invocation is a fresh fanout with fresh gate evaluation (the window may have moved, the record may have changed). A first-class deferred-delivery surface would be its own pattern (a scheduling atom), deliberately not absorbed here.
  • Message Preference staleness within an invocation. The gate evaluates the record returned by current_for at disposition-evaluation time. A suspend or superseding set committing after the gate’s read but before the create commits does not re-render the verdict — exactly as Notification Fanout commits to the subscriber set at query time, and exactly the queue-time-capture posture Message Preference’s own Intent states (“the atom does not push updates into already-queued work” — its Behavior carries the same commitment in its updates-are-not-retroactive bullet). The staleness window is one subscriber’s step-5-to-step-6 span. Deployments for which a mid-fanout suspension must win re-check current_for at the transport layer before dispatch — a transport-layer policy, outside this composition. The acceptance of staleness is deliberately asymmetric across the two actions: the original loop’s window is bounded by one invocation’s duration, so query-time audience and read-time preferences are accepted as-is; Redispose’s horizon is unbounded (a hold retry may fire days later), so it re-verifies both the audience (Subscription.subscribed — a cancel between invocation and retry yields the journaled unsubscribed suppression, never a delivery to someone who left) and the shape (a fresh current_for read), per its step 4.
  • Crash and journal-gap reconciliation. A crash mid-loop leaves a fanout.initiated entry with fewer disposition events than the reconstructed subscriber set — detectable by Check 1, surfaced as the invocation→dispositions relation’s liveness case. The composition’s reconcile_gaps(fanout_id, payload_content) surface (Action wiring), which the deployment’s scheduler invokes within the declared reconciliation_window, resolves each gap subscriber. A gap subscriber either received nothing through this composition or holds a deliver-pair orphan — a Pending record whose journal half never landed (step 6’s reachable partial) — and the leg tells the two apart before it acts: it reads Notification.pending_for for a record carrying the fanout_id, adopts one under recovery: true, and calls redispose(fanout_id, principal_ref, payload_content) only where there is none — the named crash-gap exception in Redispose step 3, where a subscriber in the journaled queried set with no disposition event is retryable. The leg examines nothing younger than invocation_duration_bound + notification_create_latency (an invocation still in its loop, or a create still landing at the lease’s edge, is not a crash) and nothing past the journal’s retention horizon, and it holds the per-disposition section for every principal it touches. For any residue it cannot resolve — a principal with more than one orphan candidate, or an invocation the deployment chooses not to repair — it appends {type: "fanout.abandoned", fanout_id, principals | all, reason, candidates | none, decided_at} (scoped to named principals or the whole invocation, at most abandonment_principals_cap per record): the terminal record that discharges the invocation→dispositions liveness arm for the covered principals. Redispose step 3 rejects any principal an abandonment record covers, so the terminal is enforced, not advisory — the at-quiescence claim is then satisfied by the abandonment record, visibly and accountably, rather than by a disposition per subscriber. Check 1’s partition is evaluated modulo this terminal — an abandoned invocation’s gap subscribers are accounted for by the abandonment record, and membership ambiguity within the tolerance is ambiguous-pending-evidence, not a clean failure. Re-invoking Fanout Shaped wholesale after a crash without Duplicate Prevention can double-deliver to subscribers whose pairs committed; Redispose, which rejects already-delivered principals, is the per-principal path that cannot.
  • The gate governs only what flows through it — the single-pipeline obligation. The quiet-window and frequency-cap invariants account for deliveries this composition commits; they say nothing about a message the deployment sends through the unshaped sibling, a direct Notification.create, or any channel outside the gate. A deployment under TCPA-class obligations must route every delivery those obligations cover through Fanout Shaped / Redispose — the same single-gate discipline Propagate Consent Revocation Downstream imposes on the consent side, where processing systems must consume its processing_permitted gate rather than reading Consent directly. The obligation cannot be proven from this composition’s records (they show what came through, not what went around); it is attested in the externally-clearable disclosures, and the breach-forensics scenario’s Notification-without-journal-pair cross-check is the records-side detector for violations of it.
  • Frequency-cap window boundary semantics. Whether a window is rolling or calendar-aligned, which timezone anchors alignment, and whether the cap counts created records (this composition’s accounting unit — the create is the send decision) or downstream delivered outcomes is fixed by frequency_limit_interpretation. This composition counts committed delivery dispositions (fanout.created events); a deployment whose regulatory cap counts transport outcomes reconciles at the transport layer.
  • Per-channel frequency caps. The interpreted cap is per principal. A deployment whose frequency_limit vocabulary encodes per-channel caps expresses them inside frequency_limit_interpretation (the interpreted (window, cap) pairs may be channel-scoped); the invariant’s accounting then partitions by channel using the channels element each delivery_count_index entry and fanout.created event already carries. The spec’s stated invariant is the principal-level bound; channel-scoped refinements are interpretation, disclosed like the rest.
  • Transport — including the re-send path after transport failure. This composition creates shaped Notification records; it does not dispatch them. The transport layer reads Notification.pending_for, honors the envelope’s channels and format, and calls deliver / fail / expire. The composition can only advise the transport — the envelope is data, not enforcement — so the deployment carries the obligation that makes it binding on its transport layer, disclosed with its peer-pattern wiring. When transport ends in failed or expired, Notification’s own retry model is a new create per attempt — and under the single-pipeline obligation that new create routes through Redispose (its step-3 transport-failure path admits a principal whose prior record reached transport failure), so the re-send re-passes the gate: a quiet window that has opened since the original send blocks it, and the cap counts the original create. A transport layer that retries by calling Notification.create directly has produced the bypass artifact breach forensics flags.
  • Scope hierarchy, wildcards, delivery ordering, payload size. Inherited unchanged from Notification Fanout’s non-goals: exact-match scopes; unordered creates; payload bounds belong to the composing layer.
  • Fan-out at scale. Inherited from Notification Fanout and sharpened by this composition’s own choices: the per-subscriber work (one current_for read, one gate evaluation, one truth-bearing pair) parallelizes freely — Invariants 1 and 5 constrain coverage, not execution strategy — but three surfaces scale with the subscriber count and are the deployment’s to size: the fanout.initiated entry journals the full queried list in one payload-capped append (size the Event Log instance’s cap to the maximum fanout scope, per Primitive policies); the single injected now’s staleness grows with loop length (the disclosed invocation-duration bound); and per-principal cap evaluation under serialized-per-principal serializes only per principal, never across the loop.
  • Payload data in the journal — and the retention obligation the digest creates. fanout.initiated journals a digest of the content, not the content — the Event Log’s payload cap (default 64 KB) and the privacy posture both argue against duplicating message content into the journal. The Notification records carry the content; the digest binds journal to records. The digest-only journal creates a named deployment obligation: every recovery path runs through Redispose, which requires the original payload_content and verifies it against the digest — but an invocation whose dispositions were all suppressed or all failed (or that crashed before any create) leaves no in-system copy of the content. The deployment must therefore retain payload_content, keyed by fanout_id, for at least its retry horizon (no shorter than the reconciliation_window, and as long as any hold-marked suppression remains schedulable) — disclosed with the peer-pattern wiring. Deployments whose audit regime requires content in the sealed journal compose Audit Trail with an explicit decision about content duplication.
  • Authorization to fanout and to redispose. Not enforced here; any caller may invoke Fanout Shaped, and any caller may invoke Redispose for any journaled invocation. Redispose does require the digest-matching payload_content — a capability-shaped bar (only a holder of the original content can re-dispose) — but that is integrity binding, not authorization. Permissions gates both actions at the composing layer; Actor Identity or Audit Trail attributes the initiator where required — and Redispose, a delivery-causing action with an unbounded horizon, is the surface attribution-required deployments should gate first. Reconcile Gaps and Reconcile Overshoots — which write the terminal fanout.abandoned, the adoption fanout.created, and the overshoot records — are invoked by the deployment’s scheduler, not by the fanout’s caller: a deployment that composes Audit Trail or Actor Identity runs them under a declared service identity of the composition’s own and gates them as it gates RedisposeRecovery commits under a declared service identity).
  • Cross-store consistency under partial failure. Four stores are touched per invocation. The per-subscriber truth-bearing pair (create, then journal) is ordered, not atomic: both members are writes their constituents declare un-withdrawable (Event Log Invariant 1, Notification Invariant 9), so no transaction encloses them, and the spec names the partial instead — the create landed, the append did not — as the deliver-pair orphan: a Pending record carrying the fanout_id in its envelope, with no fanout.created. It is bounded on one side by construction (the append needs the create’s notification_id, so no journal half ever names a record that does not exist) and repaired on the other by Reconcile Gaps, which adopts it under recovery: true inside the declared reconciliation_window — the restatement §Durability boundaries prescribes for every append-only member of a write set. The residual the deployment carries: without orphan_lookup (Configuration) the leg finds orphans through pending_for alone, and an orphan the transport dispatched before the leg ran is beyond its reach — it stays a Notification without a journal pair, which the breach-forensics cross-check surfaces after the fact; a regulated deployment declares the lookup. Everything across subscribers is independent by design (no rollback across parallel branches); the indexes are outside every atomicity surface. Two host obligations serialize on two distinct axes that both bind a Redispose deliver at once. Per-(fanout_id, principal_ref) — the per-disposition section (Configuration; the capability Invariant 9’s forward bound rests on), held by Fanout Shaped step 5, Redispose step 3, and Reconcile Gaps alike: this is also what closes the read-then-create window between Notification.status_of returning failed/expired and the new create committing, so two concurrent redisposes cannot both adjudicate the same record retryable and both deliver. Per-principal (the cap path, posture set by cap_serialization): this governs every cap evaluation for a principal, Fanout Shaped and Redispose alike. The two axes are independent — the live-record bound and the cap bound — and a Redispose deliver serializes on both. The residual partial states — journaled invocation with incomplete dispositions — are enumerated under crash reconciliation above.
  • Clock semantics. The invocation’s now is host-injected at the seam (Logic Confinement), once per invocation: fired_at and every disposition event’s decided_at carry the same injected instant, so all of an invocation’s gate evaluations share one clock reading and replay needs no per-subscriber time reconstruction. The cost is bounded staleness within one invocation — a fanout whose loop runs long evaluates its last subscriber’s quiet window against a now read at invocation start; the deployment bounds invocation duration via the declared invocation_duration_bound knob (Configuration) accordingly — and the bound is enforced, not advised: it is the per-disposition lease, at whose expiry the invocation yields its remaining subscribers to Reconcile Gaps (step 5) (a transport-layer re-check before dispatch is the mitigation for deployments where the bound is material, as with the suspension-staleness case above). Recipient-local resolution is quiet_hours_interpretation’s job under the declared clock capability; skew between the injected now and recipient wall clocks is the capability’s risk surface, named in Invariant 3’s antecedent and the externally-clearable disclosure.

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 three exposed action surfaces — the shaping fanout (Fanout Shaped), the journaled per-principal retry (Redispose), and the reconciliation pair (Reconcile Gaps, Reconcile Overshoots) — plus the composition-introduced pure gate they all run (Shaping Disposition); the correlation handle (Fanout Id) and two of the gate’s journaled data (Retry Eligible, Evaluation Inputs); the closed suppression-reason vocabulary the gate renders (Suspended, Quiet Window, Frequency Cap, No Record, Channel Opt Out, and the redispose-only Unsubscribed); the closed fail-closed cause set it introduces (Preference Unreadable, Accounting Unreadable, Interpretation Undeclared); and its own rejections — the invocation-level Journal Rejected, and the three the per-disposition section, the gap leg’s lower edge, and the orphan refusal introduce, Yielded, Too Young, and Orphan Pending. Its load-bearing guarantees — the disposition trichotomy, quiet-window and frequency-cap safety, no-silent-disposition, replayability — are structural Invariants, not data. Its emergent state (delivery_count_index, fanout_dispositions) is derived-index over the four constituent stores, carried as backticked tokens per the Execution-Contract Composition-state rule; there is no composition-introduced record store to carry a term entry as a Type, and every action input (event_scope, payload_content, principal_ref, fanout_id as input) is a relayed constituent concept, so there is no composition-introduced Parameter. The disposition-list names (created / failed / suppressed / yielded) and the journal event types (fanout.initiated / fanout.created — including its recovery: true adoption form — / fanout.suppressed / fanout.create-failed / fanout.reconcile-intended / fanout.abandoned / fanout.cap-overshoot-reconciled) stay backticked as wire values, as do the constituent calls and their outcomes — Subscription’s subscribers_for / subscribed, Notification’s create / status_of / pending_for, Message Preference’s current_for / read, Event Log’s append / read — the interpretation and policy knobs (quiet_hours_interpretation, frequency_limit_interpretation, channel_interpretation, no_record_policy, cap_policy, quiet_window_policy, cap_serialization, statutory_quiet_window, journal_query_capability, payload_digest_function, reconciliation_window, invocation_duration_bound, clock_tolerance), the relayed constituent tokens (quiet_hours, frequency_limit, channel_preferences, format, preference_id, notification_id, subscriber_ref, config_version), the inherited/relayed rejections (invalid-request, subscribers-unavailable, not-known, not-retryable, payload-mismatch) and the create-failed cause, and concrete example ids. Constituent atom names remain the existing full links to ../atoms/*; constituent operations stay backticked qualified calls, not cross-page links (the decided convention). (annotation.md Terms registry; representational only — it changes no guarantee, invariant, or behavior of the composition above.)

Fanout Shaped

The composition’s core action: fire an event to everyone subscribed, shaping each delivery by the recipient’s in-effect preferences. It queries the Active subscriber set, journals the invocation, and for each subscriber runs Shaping Disposition between the query and the create, so every subscriber gets exactly one recorded disposition — delivered, failed, or suppressed — or, at the invocation’s terminus, is left to Reconcile Gaps. Returns {fanout_id, created, failed, suppressed, yielded} or a rejection.

Kind: Operation

Redispose

The journaled, gate-evaluated retry surface for a single principal of a prior invocation — the only conformant recovery path (a failed delivery, a hold-marked suppression whose window relented, a crash-gap subscriber). It re-verifies the audience, re-runs Shaping Disposition with fresh inputs, and journals the outcome under the original Fanout Id; a direct Notification.create would bypass the gate and is branded bypass.

Kind: Operation

Reconcile Gaps

The composition-introduced reconciliation surface that resolves an invocation’s journal gap, examining only invocations older than invocation_duration_bound and inside the journal’s retention horizon: it writes its fanout.reconcile-intended record first, then for each subscriber in the journaled queried list with no disposition event — under the per-disposition section — adopts a deliver-pair orphan (recovery: true) or re-disposes through Redispose, and closes any residue with a terminal fanout.abandoned record. Owns the intent, adoption, and abandonment writes; carries no composition state (the records live in Event Log).

Kind: Operation

Reconcile Overshoots

The composition-introduced reconciliation surface that discharges the best-effort frequency-cap liveness arm: it scans a principal’s delivery events under each journaled configuration’s interpreted windows and appends a fanout.cap-overshoot-reconciled record for any window whose committed count exceeded its contemporaneous cap. Idempotent; owns the overshoot write; carries no composition state.

Kind: Operation

Shaping Disposition

The composition-introduced pure gate every disposition runs — evaluated once per subscriber per disposition, structurally between the subscriber query and each create so no create can bypass it. It reads the in-effect preference record, the injected now, the recent delivery history, and the interpretation rules, and renders one verdict by fixed precedence: deliver, suppress (with one of the six reasons), or fail-closed. Its purity is what makes replay (Invariant 7) checkable.

Kind: Operation

Fanout Id

The opaque, system-generated, invocation-unique correlation handle that binds an invocation’s journal entries together. Generated at the start of Fanout Shaped, journaled in the fanout.initiated entry before any disposition commits, and carried on every disposition event; Redispose appends under the original one.

Kind: Field Field of: the fanout result and every disposition event Role: the invocation correlation handle Projection: fanout_id

Retry Eligible

The boolean the composition records on every fanout.suppressed event marking whether the suppression may be retried after time passes: Quiet Window and Frequency Cap carry the deployment’s hold / drop policy; Suspended, No Record, Channel Opt Out, and Unsubscribed are always false (they relent only by the principal’s own action, not time).

Kind: Field Field of: the suppression event Role: the time-relents retry marker Projection: retry_eligible

Evaluation Inputs

The record of what the gate observed at disposition time — the observed record status, the injected now, and (for a cap verdict) the interpreted (window, cap) pairs and in-window counts — journaled on both fanout.created and fanout.suppressed events. It is what makes a verdict replayable (Invariant 7) and a best-effort overshoot diagnosable, because it captures the mutable and ephemeral inputs a later re-read cannot recover.

Kind: Field Field of: the disposition event Role: the recorded gate observation Projection: evaluation_inputs

Suspended

The Shaping Disposition verdict (precedence rule i) when the in-effect preference record’s status is suspended — the subscriber is suppressed even though their Subscription is Active, which is what makes Message Preference’s pause operative. Consults no count or interpretation, so it wins even during an Event Log outage.

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: suspended

Quiet Window

The Shaping Disposition verdict (rule iii, and the statutory arm of rule v) when the injected now falls inside the recipient’s stored quiet_hours, or inside a declared statutory_quiet_window for the delivering channel. The structural mechanism of quiet-window safety (Invariant 3, the TCPA arm).

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: quiet-window

Frequency Cap

The Shaping Disposition verdict (rule iv) when the in-window count of a principal’s delivery history has reached an interpreted (window, cap) pair. The structural mechanism of frequency-cap safety (Invariant 4, the TCPA frequency arm).

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: frequency-cap

No Record

The Shaping Disposition verdict (rule ii) when Message Preference.current_for returns none and the deployment’s no_record_policy is suppress — distinct from a delivery on the deliver-unshaped path, and distinct in the records from a stated opt-out (they never said versus they said no).

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: no-record

Channel Opt Out

The Shaping Disposition verdict (rule v) when interpreting the record’s channel_preferences yields an empty deliverable set — the enforcement point for honored opt-outs (Invariant 3 / CAN-SPAM). The journaled event is the honored-opt-out evidence.

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: channel-opt-out

Unsubscribed

The suppression reason Redispose alone produces, from its audience re-check: a principal who left the audience (Subscription.subscribed returns not-subscribed) between the original invocation and a later retry. Deliberately excluded from Invariant 2’s “even though Active” scope, because it is not a gate verdict against an Active subscriber.

Kind: Member Member of: the suppression reason Role: Suppression reason Projection: unsubscribed

Preference Unreadable

The pre-gate fail-closed cause: a failed Message Preference read (infrastructure failure, distinct from a successful none) raised by the orchestration before Shaping Disposition runs, since the record gates every rule. A preference-store outage degrades to this named failure — never a silent unshaped deliver to a suspended or quiet-houred principal.

Kind: Member Member of: the delivery-failure cause Role: Fail-closed cause Projection: preference-unreadable

Accounting Unreadable

The in-gate fail-closed cause raised at rule iv when a cap-carrying record’s delivery history cannot be rebuilt (the journal read fails) — raised only if no higher-precedence rule already suppressed, so a Suspended record still suppresses first.

Kind: Member Member of: the delivery-failure cause Role: Fail-closed cause Projection: accounting-unreadable

Interpretation Undeclared

The in-gate fail-closed cause raised at the precedence rule (iii / iv / v) that would consult a preference field whose interpretation the deployment never declared — never a silent deliver past an unevaluable value, never a suppression reason claiming an evaluation that did not happen.

Kind: Member Member of: the delivery-failure cause Role: Fail-closed cause Projection: interpretation-undeclared

Journal Rejected

The composition’s own invocation-level rejection when the fanout.initiated append (or a Redispose disposition append) is refused by Event Log — the composition does not run an unjournaled fanout, because every downstream guarantee reads from the journal. Covers both the retryable storage-failure and the definitive invalid-payload append outcomes under one name; on Redispose and Reconcile Gaps it carries the position — read | outcome(notification_id | none) and intent | abandonment — because on those surfaces the token can land after a write that stands.

Kind: Member Member of: the fanout rejection Role: Rejection Projection: journal-rejected

Yielded

The rejection Redispose returns when its per-disposition lease expired before its disposition committed: the call wrote nothing after the expiry, and the principal is Reconcile Gaps’s. It is also what a Redispose returns when a foreign holder — a live Fanout Shaped, or Reconcile Gaps — holds the pair’s section: the call does not wait. Its batch counterpart is Fanout Shaped’s yielded list — the subscribers an invocation left undisposed at its terminus or as a deliver-pair orphan.

Kind: Member Member of: the redispose rejection Role: Rejection Projection: yielded

Too Young

The rejection Reconcile Gaps returns for an invocation whose fired_at is younger than invocation_duration_bound + notification_create_latency: the invocation may still be in its loop, or a create it issued at the lease’s edge may still be landing, its undisposed subscribers are work in flight rather than gaps, and the leg examines nothing and writes nothing.

Kind: Member Member of: the reconcile-gaps rejection Role: Rejection Projection: too-young

Orphan Pending

The rejection Redispose returns on its crash-gap path when the pair already holds a deliver-pair orphan — a Pending record whose envelope carries the fanout_id and which no fanout.created names: the call writes nothing and creates nothing beside it, because adoption is Reconcile Gaps’s alone, under its intent record and the deployment’s service identity. Carries the record’s notification_id.

Kind: Member Member of: the redispose rejection Role: Rejection Projection: orphan-pending


Standards references

  • TCPA — Telephone Consumer Protection Act (47 U.S.C. §227) and 47 CFR §64.1200(c)(1) — restricts calls and texts outside permitted hours (the Federal Communications Commission’s implementing rule presumes no solicitations before 8am or after 9pm recipient-local time) and underwrites per-message statutory damages. Invariant 3 (quiet-window safety) is the structural mechanism: the gate evaluates the recipient’s stored quiet window in recipient-local time before any create, and the suppression record is the affirmative evidence of compliance. The statutory 8am–9pm presumption applies whether or not the principal stored a window, which is why the gate carries the statutory_quiet_window arm (Configuration) alongside the stored-preference arm — Invariant 3 covers both. Three obligations this composition names but does not itself discharge: the recipient-local clock capability, the prior-express-consent requirement (the Consent peer’s), and the single-pipeline routing obligation — the invariants cover only deliveries routed through the gate, so the deployment must route every TCPA-covered delivery through it and must declare the statutory window for the covered channels (Edge cases; externally-clearable disclosures).
  • CAN-SPAM Act (15 U.S.C. §7701 et seq., esp. §7704) — commercial email senders must honor opt-outs. The channel-opt-out suppression (an email: "opt-out" channel preference yielding an empty deliverable set or excluding the channel) is the enforcement point; Message Preference’s immutable records plus the suppression journal produce the audit trail CAN-SPAM enforcement requires. The 10-business-day honoring window is a deployment obligation on preference-change-to-enforcement latency, met structurally here because the gate reads the in-effect record at every disposition.
  • GDPR Article 7(3) — withdrawal of consent must be as easy as giving it. The Article 7(3) obligation proper belongs to the Consent atom and the Propagate Consent Revocation Downstream composition (Message Preference’s own Standards references draw this line). This composition’s contribution is the enforcement-latency half of the ease story: a withdrawal or preference change recorded upstream is honored at the next disposition evaluation — there is no cached permission to invalidate, because the gate reads current_for fresh per invocation.
  • GDPR Article 21(2) — the right to object to direct-marketing processing. Per Message Preference’s Standards references, the objection’s delivery-shaping signal lives in the preference record and the legal-permission revocation in Consent; this composition is the named enforcement point for the first half — the recorded opt-out becomes a suppression at the next evaluation.
  • CASL (Canadian Anti-Spam Legislation) and the ePrivacy Directive (2002/58/EC) — inherited through Message Preference’s standards surface; the same gate is the enforcement point.

It inherits from: Subscription (Observer, pub-sub, WebSub), Notification (SMTP — Simple Mail Transfer Protocol — disposition mapping, webhooks, the Apple and Google push services), Message Preference (CAN-SPAM, TCPA, GDPR 7(3)/21(2), CASL, ePrivacy), Event Log (append-only journaling), and the Outbox pattern via Notification Fanout’s framing — the shaped Notification records are the outbox the transport consumes.


Status

partially resolved — see the Ledger.

Ledger

status: partially resolved
formal: pending — re-derivation, 2026-08-30: the invocation and [Reconcile Gaps] are now two processes over one disposition under a per-disposition section whose lease is the invocation's terminus, the deliver pair is ordered rather than atomic with an adoptable orphan, and the gap leg is age-bounded, none of which the model carries; was verified — preference-aware-notification-fanout.tla + 1 twin, 2026-06-12
last gate: 2026-06-12 — Final Critique 12, fresh reader — clean

open:
- 2026-08-30-a · refining · [Reconcile Overshoots] record · the `action` field of `fanout.cap-overshoot-reconciled` is named in every shape and defined nowhere → declare its vocabulary or drop it
- 2026-08-30-b · refining · Edge cases, *Cross-store consistency* · without `orphan_lookup` an orphan the transport dispatched before the leg ran is unreachable at runtime and stays a Notification without a journal pair → when the Reverse Index pattern lands, name it as the lookup's constituent home and make the lookup required
- 2026-08-30-c · refining · [Redispose] hold-retry horizon · "a `hold` retry may fire days later" is unbounded, and the payload-retention obligation inherits the unboundedness → declare a hold-retry horizon after which a `hold` suppression is terminal
- 2026-08-30-d · refining · formal · the model has no [Reconcile Gaps] leg as a second process over one disposition, no per-disposition section with a lease terminus, no ordered deliver pair with an adoptable orphan, and no age-bounded leg → extend it

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/preference-aware-notification-fanout.md.

  • 2026-08-30 — The deliver pair is ordered, not atomic; one writer per disposition under a declared section; the gap leg bounded at both edges and writing its intent first. Chose: Notification.create then EventLog.append, with the reachable partial named — a Pending record carrying the fanout_id in its envelope and no fanout.created — and adopted by Reconcile Gaps under recovery: true through the declared pending_for read, with orphan_lookup as the optional capability that reaches an orphan in any status; a per-disposition section keyed by (fanout_id, principal_ref), declared as an instance capability requirement with lease semantics, held by Fanout Shaped, Redispose, and Reconcile Gaps alike, its expiry the holder’s terminus (yielded on the batch result, rejected(yielded) on Redispose); Reconcile Gaps examining nothing younger than invocation_duration_bound (rejected(too-young)) and nothing past the journal’s retention horizon, writing fanout.reconcile-intended before any repair, and abandoning under the section at most abandonment_principals_cap principals per record; journal-rejected(read | outcome(notification_id | none)) and journal-rejected(intent | abandonment) carrying the position; the three-term liveness inequality with reconciliation_cadence and disposition_write_latency, checked at start; max_query_latency widening Check 1’s window; the largest writable record sized, not only the intent. Over: a pair claimed to land together or not at all over two writes both constituents declare un-withdrawable, and a gap leg that read “a Notification without a journal pair cannot exist” as licence to redispose; a bare host-conformance serialization that ordered the invocation’s write after the leg’s instead of preventing it; a leg with no lower edge that read a loop still running as a crash; one bare token on both sides of the create. Because: an append-only journal and a never-deleted record cannot be enlisted in one transaction, so the honest claim is order plus a named partial plus its repair; two writers over one disposition land two live notifications for one principal, which Invariant 9 exists to forbid; a leg that corrects work in flight is a wrong act, not a wrong report; and a caller who cannot tell read from outcome re-runs a committed create (the frozen rules of 2026-08-30 — A compensator is exclusive, Liveness is arithmetic, An outcome is sized before the intent, A stamp from another seam never decides a write alone, A composition’s own rejection arm carries the retry bit, and Capability provenance — with §A reconciliation is bounded at both ends, §Recovery commits under a declared service identity, and §Durability boundaries). The same day’s closure check moved four more things: the lease is mandated rather than optional (a section that cannot expire is not a conforming section) and is reentrant for its holder alone, so Reconcile Gaps calls Redispose as the holder and a foreign-held pair refuses with yielded; adoption is the leg’s alone — a caller’s Redispose that finds an orphan returns rejected(orphan-pending(notification_id)) and writes nothing; the leg’s lower edge is invocation_duration_bound + notification_create_latency, because a create issued at the lease’s last instant is still landing when a pending_for read at the bare bound would miss it; and the leg’s arms are closed — payload_content | none and abandon: true as declared arguments, rejected(reason) entries carried to the next run rather than abandoned, journal-rejected(intent | adoption(notification_id) | abandonment), and fanout.abandoned.reason enumerated.
  • 2026-06-11 — Delivery frequency accounting is a rule inside the shaping disposition, not an extracted concept. Chose: the frequency-cap verdict is a pure function over Event Log’s delivery history and the injected clock, evaluated inside shaping_disposition. Over: a Delivery Frequency Accounting atom. Because: it has no state machine of its own, no freestanding surface to compose, and another pattern would reinvent one Event Log read, not a concept.