Preference-Aware Notification Fanout
Table of contents
- Preference-Aware Notification Fanout
- Summary
- Intent
- Composes
- Composition logic
- Composition-level invariants
- Examples
- Walkthrough — one invocation, all three dispositions
- Frequency cap firing
- Best-effort cap overshoot and reconciliation
- Statutory window firing — no stored quiet hours
- Marketing newsletter — channel opt-out under CAN-SPAM
- Rejection paths
- Fail-closed gate — preference store outage
- Partial failure
- Regulated adversarial scenarios
- Generation acceptance
- Edge cases and explicit non-goals
- Terms
- Standards references
- Status
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 querysubscribed(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), and thestatus_of(notification_id)query that Redispose reads to adjudicate transport-failure retries. 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)(andread(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 opaquechannel_preferences,frequency_limit,quiet_hours, andformatvalues 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 viaread(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 fromprincipal_refto the(decided_at, channels)of that principal’s recent delivery dispositions, consulted by the frequency-cap evaluation (the channels ride along so a deployment whosefrequency_limit_interpretationdeclares 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 declaredread(query)surface under the declaredjournal_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 afanout.createdevent’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 Lineage notes §Formal model).fanout_dispositions— map fromfanout_idto the invocation’s disposition lists, consulted by audit queries. Classification: derived index. Derivation: the Event Log store, throughread(query)under the declaredjournal_query_capability. Rebuild procedure:EventLog.read(query: events where fanout_id = F)— thefanout.initiatedevent carries the invocation’s scope, payload digest, andfired_at; the per-subscriberfanout.created/fanout.suppressed/fanout.create-failedevents carry the dispositions; the map is their grouping byfanout_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 storedquiet_hoursvalue to a predicate over the injectednow: is this instant inside the principal’s quiet window? Must resolve the window in the recipient-local time the stored value declares (e.g., thetimezonefield 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 carryingquiet_hoursunder an undeclared interpretation fails closed per the preamble rule.frequency_limit_interpretation— the rule mapping a storedfrequency_limitvalue 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 storedchannel_preferencesmap to the deliverable channel set for a disposition (e.g., values"opt-out"exclude the channel; everything else includes it), and mapping a storedformatvalue to the envelope’s format field — including the format applied when a record supplieschannel_preferencesbut noformat(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 (seeno_record_policy) and any record that carries preference fields but nochannel_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 intosuppress(channel-opt-out)— a suppression whose recorded reason names an opt-out no one stated; a deployment that wants no-record principals suppressed declaresno_record_policy = suppressand gets the honestno-recordreason instead. No default interpretation; the default shape is also deployment-declared.no_record_policy—deliver-unshaped | suppress. What the disposition is whenMessage Preference.current_for(principal_ref)returnsnone: deliver using the declared default shape, or suppress with reasonno-record.deliver-unshapedis well-formed only whenchannel_interpretation’s default shape is declared; set without one, the no-record deliver path fails closed asfail(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_policy—drop | 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 —holdis a classification on the record, not a queue (see Edge cases). Default:drop.quiet_window_policy—drop | 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_serialization—serialized-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. Underserialized-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 staledelivery_count_indexentry 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; Redispose’s own per-(fanout_id, principal_ref)serialization (its 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. Underbest-effort, concurrent invocations can each readcount = cap − 1and 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 declareserialized-per-principal.journal_query_capability— the deployment’s declared guarantee that its Event Log instance’sread(query)supports the predicates this composition’s derivations and audits require: filter by event type, byfanout_id, byprincipal_ref, and bydecided_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 byfanout_idand 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 topayload_contentfor thefanout.initiatedentry 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. Disclosed with the other knobs.invocation_duration_bound— the maximum wall-time a single Fanout Shaped invocation’s loop may span. The onenowis injected once at the seam (Clock semantics), so a long loop evaluates its last subscriber’s quiet/statutory window against anowread at invocation start; Invariant 3’s quiet-window safety for that late-loop subscriber rests on this bound being small enough that the injectednowresolves the same quiet/statutory window when the subscriber is evaluated as it did atfired_at. A duration; required for any deployment carrying Invariant 3’s regulated (TCPA) arm — set no larger than the safety margin of the narrowest quiet or statutory window the deployment must honor; a deployment may declare itunboundedonly by disclosing that no statutorily time-restricted channel is in scope (the same posture as omittingstatutory_quiet_window). 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 injectednowis inside the window, and an envelope emptied by the exclusion suppresses asquiet-window. Resolved recipient-locally under the same clock capability asquiet_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 injectednow(which stampsfired_at/decided_at) and the constituent stores’ own write clocks (Subscription’ssubscribed_at/cancelled_at, Message Preference’sset_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 toSubscription.subscribers_for. Validated before any id is generated or constituent called. The composition imposes no semantic length cap, but the composing layer must boundevent_scope— and the deployment must size the Event Log instance’s payload constraint (default 64 KB) for thefanout.initiatedentry at its maximum fanout scope, since the entry journals the fullqueriedlist — so that the invocation entry fits; an oversized entry surfaces asjournal-rejectedat step 4, a definitive rejection, not a retryable outage.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_contentis 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 nofanout.initiatedentry isnot-known, notinvalid-request(Redispose steps 1–2).principal_refandpayload_contentcarry the same rules on Redispose as on Fanout Shaped.principal_ref/subscriber_refequality — the composition equates Subscription’ssubscriber_refand Message Preference’sprincipal_refbyte-exactly, with no normalization. Message Preference declares exactly this posture forprincipal_ref(exact equality, no canonicalization); Subscription stores and matchessubscriber_refas an opaque value it never parses or normalizes but states equality semantics explicitly only forevent_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 subscribesUser-42but records preferences underuser-42getsnonefromcurrent_forand theno_record_policypath, 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), ...]} | rejected(invalid-request | subscribers-unavailable | journal-rejected)
- Validate inputs.
event_scopenon-empty;payload_contentnon-null. Either failure →rejected(invalid-request). No id is generated; no constituent is called. - 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 — wherefanout_idis ephemeral unless the caller composes Event Log — here the id is always durable: step 4 journals it. - Query the subscriber set:
Subscription.subscribers_for(event_scope). If the subscription store is unavailable (infrastructure failure at the read), returnrejected(subscribers-unavailable); nothing has been written; the generatedfanout_idis discarded. -
Journal the invocation: EventLog.append({type: "fanout.initiated", fanout_id, event_scope, queried: [subscriber_ref, ...], config_version, payload_digest, fired_at}), wherequeriedis 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_versionidentifies the deployment’s interpretation-configuration version in force (Configuration preamble),fired_atis the host-injectednowof the invocation, andpayload_digestis the declaredpayload_digest_functionapplied topayload_content(the digest, not the content, is journaled — see Edge cases, Payload data in the journal). If the append is rejected (invalid-payloadstorage-failure), returnrejected(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 oversizedevent_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: []}— a valid, journaled, empty fanout. - For each
subscriber_refin the queried set, evaluate the shaping gate. ReadMessage 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 injectednow; the principal’s recent delivery history fromdelivery_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 noquiet_hoursevaluates 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-unreadableis absent from the gate’s codomain, it never routes throughno_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_windowexclusion emptied it (the exclusion is recorded inevaluation_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 nochannel_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
setorsuspendcommitting 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.
- Commit the disposition.
-
Deliver verdict: call Notification.create(subscriber_ref, {content: payload_content, channels, format}), thenEventLog.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 (activenone; a deliver verdict is never rendered onsuspended), the injectednow, where the record carried afrequency_limitthe interpreted(window, cap)pairs with the in-window count the gate computed for each, and — where astatutory_quiet_windowis 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 (perhapsdeletedafter supersession), so the status the gate saw lives in the event, not in the record. Recording the observation on thefanout.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 and commit under the action’s multi-write atomicity obligation (execution-contract.md§Multi-write atomicity) — the delivery record and its journal evidence land together or not at all for this subscriber. If the pair cannot commit (Notification’sinvalid-request, an infrastructure failure on either write — handled at this boundary exactly as Notification Fanout handles them), 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)tofailed. The gate’s fail-closed outcomes land the same way, with their causes:preference-unreadable(raised before the gate runs) and the gate’s ownfail(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 tofailedin the returned result, and the journal’s incompleteness for this invocation is detectable as a coverage gap againstfanout.initiated(see Invariant 1’s at-quiescence form and Edge cases, Crash and journal-gap reconciliation). Thedelivery_count_indexentry for the principal is updated after the pair commits — outside the pair’s atomicity surface, 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_inputsrecords what the gate saw (the observed record status —activesuspendednone— recorded because status is Message Preference’s one mutable field and replay cannot recover it from the record later; the injectednow; 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-windowandfrequency-capcarry thequiet_window_policy/cap_policymarking (hold→ true,drop→ false);suspended,no-record,channel-opt-out, andunsubscribed(a reason only Redispose produces — see its step 4) are alwaysfalse— those suppressions relent only by the principal’s own action (a freshset, a freshsubscribe), 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)tosuppressed. A rejected suppression append is the same journal-gap case as above: the entry joins the returnedsuppressedlist and the gap is reconcilable againstfanout.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.
-
- Return
{fanout_id, created, failed, suppressed}. The three lists are unordered.
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 | journal-rejected)
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 — the exact artifact the breach-forensics scenario treats as structural evidence of bypass. Redispose keeps both guarantees: the gate re-evaluates with fresh inputs, and the outcome is journaled under the original invocation’s fanout_id.
- Validate:
payload_contentnon-null,principal_refnon-empty,fanout_idnon-empty; otherwiserejected(invalid-request). - Resolve the invocation: the (non-empty)
fanout_idmust resolve (via the journal) to afanout.initiatedentry; otherwiserejected(not-known). If the journal read itself fails (infrastructure),rejected(journal-rejected)— nothing has been evaluated or written. The suppliedpayload_contentmust digest (under the declaredpayload_digest_function) to the entry’spayload_digest; otherwiserejected(payload-mismatch)— the retry is bound to the original content, which is what lets Invariant 8 extend across redispositions. - Check retryability: a principal covered by a
fanout.abandonedrecord for thisfanout_idisrejected(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 afanout.create-failed, afanout.suppressedwithretry_eligible: true, or afanout.createdwhose Notification record’s status — read viaNotification.status_of(notification_id), the constituent’s declared query surface — isfailedorexpired(the transport-failure retry path: Notification’s own retry model is a newcreateper 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 isfailedorexpiredis the deployment’s disclosed fail-vs-expire policy, per Notification’s Generation acceptance); otherwiserejected(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 thisfanout_idis admitted only on the crash-gap path: the principal must appear in thefanout.initiatedentry’s journaledqueriedlist — the invocation’s true scope, read from the journal, never reconstructed at runtime against Subscription’s stores; otherwiserejected(not-retryable)— a principal absent from the journaledqueriedlist was never in the invocation’s scope and has no gap to repair. Serialization: this check and step 4’s commit must be serialized per(fanout_id, principal_ref)— against concurrent Redispose calls and against the original invocation’s disposition commit for the same principal — a host conformance requirement of the same kind as Message Preference’s per-principal write serialization. Without it, two concurrent redisposes can both observe a retryable latest event and both deliver; Invariant 9’s forward bound rests on this named obligation, so the requirement is conformance, not configuration (see Edge cases — Cross-store consistency under partial failure). - 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 (aholdretry may fire days later), so the audience must be re-verified. Onnot-subscribed, commitfanout.suppressedwith reasonunsubscribed,retry_eligible: false,preference_id: none, andevaluation_inputsrecording the audience re-check outcome (not-subscribed) and the injectednow— 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-injectednow, a freshMessage Preference.current_forread, the current delivery history (the cap evaluation here participates in thecap_serializationper-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 (the deliver pair’s journal half, a suppression event, or a failure event) surfaces asrejected(journal-rejected)with nothing committed — and for the deliver pair nothing committed is enforced by the same cross-store multi-write atomicity boundary step 6 invokes (execution-contract.md§Multi-write atomicity: theNotification.createand its journal evidence land together or not at all, so a rejected journal half rolls the create back rather than stranding a live notification with no journal pair) — where the batch loop must instead degrade to a returned-list entry plus a reconcilable journal gap — the single-principal scope makes the clean rejection achievable, so Redispose never widens a journal gap. The committed event carries the originalfanout_idplusredisposition: true. The per-principal disposition chain under onefanout_idis 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 within the declared reconciliation_window to discharge the liveness arms of the invocation→dispositions relation and Invariant 4. They are the composition-introduced, capability-provenanced home for the two reconciliation event types — fanout.abandoned and fanout.cap-overshoot-reconciled — which no constituent action and no Fanout Shaped / Redispose step writes. 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) → {repaired: [(principal_ref, created | failed | suppressed)], abandoned: [principal_ref | all]} | rejected(invalid-request | not-known | payload-mismatch | journal-rejected) — resolve an invocation’s journal gap. Resolve the fanout.initiated entry (else not-known); verify payload_content against its journaled payload_digest under the declared payload_digest_function (else payload-mismatch), since repair re-disposes through Redispose, which is content-bound. 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 — call redispose(fanout_id, principal_ref, payload_content) (its crash-gap admission path, Redispose step 3, accepts exactly these principals). For any residue the deployment cannot resolve or chooses not to repair, append {type: "fanout.abandoned", fanout_id, principals | all, reason, decided_at} — 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). A rejected append surfaces as rejected(journal-rejected) with nothing committed — a single record, no truth-bearing pair to orphan.
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.
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), 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; 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_idthat resolves to afanout.initiatedentry. 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 againstfanout.initiatedand, within the declaredreconciliation_window, either re-disposed via Redispose or terminally discharged by a journaledfanout.abandonedrecord 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_policypath). Read-only — this composition never writes the relation. Referential integrity: everypreference_idrecorded in a disposition event resolves viaMessage Preference.readto a record that was in effect atdecided_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), thecreated,failed, andsuppressedlists partition the subscriber set returned bySubscription.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| = |subscribers_for result|. 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); Event Log Invariants 1–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 noNotification.createin 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 — asuspendcommitting 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_fordeterminism); 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_interpretationand supplies the recipient-local clock/timezone discipline that interpretation requires (a deployment-declared capability: the host’s injectednowand the interpretation’s timezone resolution must be sound for the recipient’s locale), then: noNotification.createcommits in any invocation for a principal whose preference record as observed by the gate at disposition evaluation carriesquiet_hourscontaining the injectednowunder the declared interpretation; and, where the deployment declares astatutory_quiet_window, no committed envelope names a channel inside that window at the injectednow(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 supersedingsetlanding 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 storedquiet_hoursvalue is immutable — what the gate read is what the principal stated); the composition-introduced gate (step 5.iii); the deployment-declaredquiet_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-declaredinvocation_duration_bound(Configuration), on which the injectednow’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’sevaluation_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) undercap_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; (underbest-effort) two invocations may each observecount = cap − 1and 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 (eachfanout.createdevent’s recorded observation shows the headroom the gate saw), and is detected and recorded within the declaredreconciliation_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 journaledconfig_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 Lineage notes §Formal model). One further clock assumption is named rather than hidden: the window arithmetic comparesdecided_atvalues stamped by different invocations’ injections ofnow, so the accounting assumes the host clock is non-decreasing across invocations to within the declaredclock_tolerancecomponent (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 Invariants 1–4 (the delivery events are append-only, immutable, totally ordered — the count’s derivation source) under the declaredjournal_query_capability; the composition-introduced gate anddelivery_count_index(a derived index, outside the atomicity surface by construction); the deployment-declaredcap_serializationcapability andclock_tolerance. - Invariant 5 — No silent disposition. Every suppression and every delivery failure lands as a classified Event Log event (
fanout.suppressedwithreason,preference_id | none,evaluation_inputs,retry_eligible;fanout.create-failedwithcause,preference_id | none) bound to its invocation byfanout_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’sappendaction and Invariants 1–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_forandsubscribedare its only Subscription calls), never writes the Message Preference store (current_for/readonly), and writes the Notification store only throughcreate(status_ofis its only other Notification call). All Subscription invariants, all Message Preference invariants (1–10 plus Temporal property 11), 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 (
unsubscribedevents record Redispose’s audience re-check, not a gate verdict — their replay is the recorded point-query outcome itself; the gate’sfail(accounting-unreadable | interpretation-undeclared)verdicts record an infrastructure-or-configuration condition rather than a reproducible deliver-or-suppress verdict, and are journaled asfanout.create-failed— all of these are outside this invariant’s quantifier), re-evaluating the gate with the event’s recorded inputs (preference record viaMessage Preference.read, injectednow, the observed in-window counts fromevaluation_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 mutablestatusis 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:nowinjected, 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 samecontentelement in its envelope: thepayload_contentwhose digest the invocation’sfanout.initiatedentry recorded (Redispose verifies the digest before re-evaluating, so the binding extends across redispositions). The envelope’schannelsandformatfields 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-boundpayload_content, wrapped per recipient); Notification Invariant 1 (the created record’s payload is immutable); the deployment-declaredpayload_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 thefanout_idis afanout.createdwhose record is Pending or Delivered, admitting a created-again retry only when the prior record has reached transportfailedorexpired(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 are serialized per(fanout_id, principal_ref)(the named host conformance requirement of Redispose step 3). Rests on: Subscription Invariant 6; Notification Invariants 2–4 (status monotonicity — no record returns from a terminal state, so afailed/expiredadjudication is stable — plus terminal exclusivity and status–timestamp match: together what makes “live” decidable fromstatus_of); action wiring steps 5–6; Redispose step 3, including its named per-(fanout_id, principal_ref)serialization requirement. - Invariant 10 — Invocation identity is unique and journaled. Each non-rejected invocation carries a Fanout Id no other invocation shares, journaled in
fanout.initiatedbefore 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 Invariants 1–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 (Invariants 2–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 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.initiatedjournaled withqueried: [ana, ben, cho, dia],config_version: cfg_v3, andfired_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.createdjournaled. →created. - ben: record Suspended →
suppress(suspended);fanout.suppressedjournaled withpreference_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.suppressedjournaled withevaluation_inputscarrying the injectednowand the interpreted window,retry_eligible: true(policyhold). →suppressed. - dia:
current_for(dia) → none; policydeliver-unshaped→ default shape →Notification.create(dia, {content, channels: [email], format: "plain"}) → notif_92;fanout.createdjournaled withpreference_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)]}.
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 none → suppress(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.initiatedappend 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 definitiveinvalid-payload(an oversizedevent_scope); retry distinguishes the two. redispose(fx_01, ana, payload)when ana’s latest disposition underfx_01isfanout.createdandstatus_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 oncenotif_91has reachedfailedorexpired).redispose(fx_99, cho, payload)where nofanout.initiatedentry carriesfx_99→rejected(not-known);redispose(fx_01, cho, altered_payload)where the digest does not matchfx_01’s journaledpayload_digest→rejected(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 create hits an infrastructure failure mid-commit — the truth-bearing pair aborts together, fanout.create-failed is journaled with cause create-failed, eli → failed; fay’s pair commits (created). Returns all three classified. 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 the unjournaled Notification that breach forensics reads as bypass evidence. 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.createdevents in the audit period (Event Logread, time-ranged); for each, fetch the recordedpreference_idviaMessage Preference.read(immutable, durable); where the record carriesquiet_hours, evaluate the deployment’s disclosedquiet_hours_interpretationagainst the event’sdecided_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 samplesfanout.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 ≤ twithin the in-effect window); the disposition — either afanout.createdevent whosedecided_at,channels, andpreference_idshow 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 noquiet_hours, the delivery was conformant with respect to stated preferences — and the declaredstatutory_quiet_windowis 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’sevaluation_inputsshow 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.initiatedentry’sfired_at(Check 1’s procedure) versus the disposition events under itsfanout_id(Invariant 1 — a delivery with nofanout.createdevent, or a reconstructed subscriber with no disposition past the reconciliation window, is structural evidence of bypass); Notification records created in the window versusfanout.createdevents (a Notification whose creation has no journal pair 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 Invariants 1–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: thefanout.initiatedentry yieldsevent_scope, the journaledqueriedlist (the invocation’s true scope),config_version,payload_digest, andfired_at. The disposition events grouped byfanout_id, taking the latest event per principal where redispositions have appended, partition the journaledqueriedlist — every queried subscriber in exactly one of delivered / failed / suppressed (Invariant 1), with a journaledfanout.abandonedrecord terminally accounting for the principals it covers. As a cross-check on the queried list itself, the auditor reconstructs the Active set atfired_atfrom the Subscription store’s audit surface (Subscription’s historical filter:subscribed_at ≤ fired_atand (status = activeorcancelled_at > fired_at)) and compares: divergence beyond the disclosedclock_toleranceoffired_atis 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 injectednowfrom the host’s). Eachfanout.createdevent’snotification_idresolves viaNotification.status_ofto a record with matchingrecipient_ref. - Check 2 — Verify frequency-cap safety per delivery. For each
fanout.createdevent whoseevaluation_inputscarry 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, underserialized-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). Underbest-effort, any committed overshoot must be matched by afanout.cap-overshoot-reconciledrecord whosedecided_atfalls within the disclosedreconciliation_windowof 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.suppressedevent carries areasonfrom the closed set{suspended, no-record, quiet-window, frequency-cap, channel-opt-out, unsubscribed}(unsubscribedonly onredisposition: trueevents), apreference_idresolving viaMessage Preference.readto a record in effect atdecided_atwithin the disclosedclock_tolerance— ornone, exactly when no in-effect record was read: the gate observed statusnone(theno-recordreason, or a no-record deliver path emptied by the statutory exclusion), or the disposition isunsubscribed, whoseevaluation_inputsrecord the audience re-check outcome and the injectednowrather than a gate observation (the re-check never reaches the gate); the recordedpreference_idis authoritative for what was read, the in-effect reconstruction is the cross-check — plusevaluation_inputsandretry_eligible(the step-6 totality rule fixes it for every reason). Everyfanout.create-failedevent carries acausefrom the closed set{create-failed, preference-unreadable, accounting-unreadable, interpretation-undeclared}(Invariant 5 covers both halves). - Check 4 — Trace any
fanout_idto its complete disposition set. Every disposition event’sfanout_idresolves to exactly onefanout.initiatedentry; no disposition event is orphaned; no twofanout.initiatedentries share an id; redisposition events carryredisposition: trueand 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 journaledqueriedlist with no prior disposition event (Invariant 10; the invocation→dispositions referential integrity). - Check 5 — Replay any disposition verdict. For a sampled set of
fanout.suppressedandfanout.createdevents, re-evaluate Shaping Disposition from the recorded inputs per Replay semantics — both event types carryevaluation_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 envelopecontentdigests (under the disclosedpayload_digest_function) to thefanout.initiatedentry’spayload_digest— across the original invocation and all redispositions (Invariant 8).
Externally-clearable 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,invocation_duration_bound,clock_tolerance(both declared components — see the knob), andstatutory_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 (everyconfig_version’s rules retained for the audit horizon, the ground Checks 2 and 5 replay against) and the declaredinvocation_duration_bound(Configuration) — the staleness bound on which Invariant 3’s wall-time meaningfulness for late-loop subscribers depends. 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) within the declared
reconciliation_window; those surfaces — not the deployment’s own code, and not Fanout Shaped / Redispose — own the writes of the composition-definedfanout.abandonedandfanout.cap-overshoot-reconciledevents, 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 areconciliation_windowbut 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 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
nowand the timezone resolution behindquiet_hours_interpretationare 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_refand Message Preference’sprincipal_refdenote 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_contentkeyed byfanout_idfor 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.
Edge cases and explicit non-goals
- 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 = holdandquiet_window_policy = holdmark 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_forat disposition-evaluation time. Asuspendor supersedingsetcommitting 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-checkcurrent_forat 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 (aholdretry may fire days later), so it re-verifies both the audience (Subscription.subscribed— a cancel between invocation and retry yields the journaledunsubscribedsuppression, never a delivery to someone who left) and the shape (a freshcurrent_forread), per its step 4. - Crash and journal-gap reconciliation. A crash mid-loop leaves a
fanout.initiatedentry with fewer disposition events than the reconstructed subscriber set — detectable by Check 1, surfaced as the invocation→dispositions relation’s liveness case. The composition’sreconcile_gaps(fanout_id, payload_content)surface (Action wiring), which the deployment’s scheduler invokes within the declaredreconciliation_window, resolves each gap subscriber. A Notification record without a journal pair cannot exist (the truth-bearing pair commits atomically per subscriber), so a gap subscriber received nothing; Reconcile Gaps callsredispose(fanout_id, principal_ref, payload_content)for each — the named crash-gap exception in Redispose step 3, where a subscriber in the reconstructed queried set with no disposition event is retryable. For any residue it cannot resolve — a principal whose membership stays ambiguous within theclock_tolerance, or an invocation the deployment chooses not to repair — it appends{type: "fanout.abandoned", fanout_id, principals | all, reason, decided_at}(scoped to named principals or the whole invocation): 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 itsprocessing_permittedgate 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.createdevents); 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_limitvocabulary encodes per-channel caps expresses them insidefrequency_limit_interpretation(the interpreted(window, cap)pairs may be channel-scoped); the invariant’s accounting then partitions by channel using thechannelselement eachdelivery_count_indexentry andfanout.createdevent 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’schannelsandformat, and callsdeliver/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 infailedorexpired, 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 callingNotification.createdirectly 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_forread, 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: thefanout.initiatedentry journals the fullqueriedlist in one payload-capped append (size the Event Log instance’s cap to the maximum fanout scope, per Primitive policies); the single injectednow’s staleness grows with loop length (the disclosed invocation-duration bound); and per-principal cap evaluation underserialized-per-principalserializes only per principal, never across the loop. - Payload data in the journal — and the retention obligation the digest creates.
fanout.initiatedjournals 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 originalpayload_contentand 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 retainpayload_content, keyed byfanout_id, for at least its retry horizon (no shorter than thereconciliation_window, and as long as anyhold-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. - Cross-store consistency under partial failure. Four stores are touched per invocation. The per-subscriber truth-bearing pair (create + journal) is atomic per the Contract’s multi-write obligation; everything across subscribers is independent by design (no rollback across parallel branches); the indexes are outside every atomicity surface. Two host serialization obligations are conformance requirements, not knobs, and they serialize on two distinct axes that both bind a Redispose deliver at once. Per-
(fanout_id, principal_ref)(Redispose step 3 — the obligation Invariant 9’s forward bound rests on): this is also what closes the read-then-create window betweenNotification.status_ofreturningfailed/expiredand the newcreatecommitting, so two concurrent redisposes cannot both adjudicate the same record retryable and both deliver. Per-principal (the cap path, posture set bycap_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
nowis host-injected at the seam (Logic Confinement), once per invocation:fired_atand every disposition event’sdecided_atcarry 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 anowread at invocation start; the deployment bounds invocation duration via the declaredinvocation_duration_boundknob (Configuration) accordingly (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 isquiet_hours_interpretation’s job under the declared clock capability; skew between the injectednowand 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 card here. A card states what the concept is, in plain English, plus its Kind — one of four: Type (a thing or category), Operation (a behavior), Member (a value of an enumerated Type), or, for a named datum, Field (a datum a Type carries — what does it carry?) or Parameter (a value an Operation needs — what does it need?). A card also names the Type it is a Member of / Field of, the Operation it is a Parameter of, and its Role where the domain assigns one. A card carries one Projects line — the concept’s single canonical lowering token, the one place the concrete name stays visible on the page — for every Field, Parameter, and pinned/wire Member. Everything else about casing (each target’s snake / camel / pascal / const / wire form) is derived from that one token by tools/harness/term-adapter.mjs, never hand-written. This is a composition, so its own concepts are: the 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 invocation-level rejection (Journal Rejected). 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 card 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) and the journal event types (fanout.initiated / fanout.created / fanout.suppressed / fanout.create-failed / 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, 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. Returns {fanout_id, created, failed, suppressed} 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: for each subscriber in the journaled queried list with no disposition event it re-disposes through Redispose, and any residue it cannot resolve is closed with a terminal fanout.abandoned record. Owns the abandonment write; carries no composition state (the record lives 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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 Projects: 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.
Kind: Member Member of: the fanout rejection Role: Rejection Projects: journal-rejected
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_windowarm (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_forfresh 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
grounded on Final Critique 12 — 2026-06-12 — authored 2026-06-11 against the adjudicated kickoff plan; foundation round plus nine fresh-reader Final Critique rounds (Final Critique 4: 22 findings, 6 foundational; Final Critique 5: 14, 2; Final Critique 6: 13, 3; Final Critique 7: 14, 3; Final Critique 8: 10, 2; Final Critique 9: 13, 0 foundational — the first grounding gate). A post-grounding fresh-reader rescan reopened the gate: Final Critique 10 (6 findings, 1 foundational — a gate fail-closed/precedence ordering defect); Final Critique 11 (10 findings, a foundational reconciliation-surface provenance cluster — resolved by introducing the composition-introduced reconcile_gaps / reconcile_overshoots surface, taking the action surface to three); Final Critique 12 (5 findings, 0 foundational — the re-grounding gate). All findings closed in-pattern every round, Pass 2 clean from Final Critique 8 on; formal-layer vote YES and discharged (TLA+ cap-TOCTOU model + overshoot buggy twin verified in tools/harness/, bound saturation recorded; the Final Critique 10–12 changes do not touch the modeled race — see the §Formal model coverage note); regulated overlay carried in full. The eighteenth and final C-numbered composition.
Lineage notes
Drafted as the composition that retires the final unstarted C-numbered roadmap row, unblocked 2026-05-29 when Message Preference grounded. The constituent-set decision — compose Subscription + Notification + Message Preference + Event Log directly; do not compose Notification Fanout — was adjudicated in the 2026-06-11 planning session on the Reserve from Pool precedent (Reserve from Pool re-wires Idempotent Reservation’s atoms rather than wrapping the grounded composition, because the smaller composition’s action surface exposes no insertion point). Notification Fanout’s fanout has no per-subscriber hook between query and create; the gate must sit exactly there. Audit Trail and Consent were adjudicated peers, not constituents: attribution is optional to the mechanism, and legal permission is sequenced upstream by Message Preference’s own boundary declaration.
Structural milestone. Retires the forthcoming-link debts standing against this composition: the forthcoming-link marker in Message Preference’s Composition notes and the marker on Propagate Consent Revocation Downstream’s Preference-Aware Notification Fanout boundary note in Propagate Consent Revocation Downstream — both now resolved to links to this composition. Corrects the roadmap’s row for this composition, which listed Notification Fanout as a constituent.
Conventions inherited from prior work. Regulated adversarial scenarios and Generation acceptance (split into record-clearable / externally-clearable) inherited from the methodology directly (pressure-testing.md §Regulated-pattern conventions); the structural-relation templates from spec-format.md §Cross-cutting authoring conventions; the composition-state classification discipline from execution-contract.md §Composition state. Authored under the 2026-06-11 vocabulary direction (lint rules J and K) from the first line.
Pass 2 adjudication — candidate concept “Delivery Frequency Accounting” (flagged by the kickoff plan; adjudicated at drafting). The frequency-cap accounting — count a principal’s deliveries per window, render a verdict — is concept-shaped: it recurs across domains (API throttling, login-attempt caps, spend limits), which the four extraction questions take seriously. Run honestly: (1) recurs across domains? Yes. (2) own state machine? No — the verdict is a pure function over the delivery history (Event Log events) and the injected clock; the “counter” has no lifecycle, no transitions, no states of its own; the history it reads is Event Log’s store, governed by Event Log’s invariants. (3) specifiable without it, composed in? The evaluation is one rule inside shaping_disposition; there is no freestanding surface to compose. (4) would another pattern reinvent it? Another pattern would reinvent the counting query, which is one Event Log read — not a concept’s worth of reinvention. Gate 3 of the composition-layer extraction gate settles it: the recurrence introduces no state the constituents do not already carry — the deliveries live in Event Log, the limit lives in Message Preference, the verdict is derived — so it names wiring, not a freestanding concept. The Execution Contract’s materialized-projection tiebreaker confirms the boundary and names the re-opening condition: the moment a deployment requires the cap bound to be unconditional without host serialization — i.e., the counter must be transactionally consistent with the deliveries it counts — the counter is the materialized-projection atom (a true Rate Limiter, with its own store, identity, and invariants), not an index. this composition deliberately does not require that: Invariant 4 is conditional on the declared cap_serialization capability, so the count stays a derived index and the extraction is not warranted now. Recorded as considered-and-declined with the falsifiable re-opening condition.
Formal-layer vote — 2026-06-11: YES. Invariant 4’s frequency-cap bound under concurrent invocations is a genuine action-vs-action race: two fanout_shaped invocations evaluating the same principal can both observe count = cap − 1 and both commit — the same time-of-check-to-time-of-use class as capacity-constraint-enforcement.tla’s overshoot twin. A derived TLA+ model verifies the serialized form holds and the split observe/commit form overshoots. The suspend-vs-fanout race (a suspend landing between the gate’s current_for read and the create’s commit) is deliberately not modeled, with the named reason: Invariant 2 is stated at disposition-evaluation time (the queue-time-capture posture Message Preference’s own Behavior blesses), so the interleaving violates no stated invariant — there is no property for a model to check, only a staleness window the spec names in Edge cases.
Round 1 — foundation (Pass 1 → 2 → 3, author-led, 2026-06-11). Pass 1 walked the nine GRID nodes, the reference graph (every Rests on: clause resolved against the constituent specs’ invariant lists as written — Subscription’s nine, Notification’s nine, Message Preference’s ten plus Temporal property 11, Event Log’s seven), and the accessibility checks. Pass 2 ran the extraction questions — the flagged Delivery Frequency Accounting adjudication is recorded above; the hold-marking (scheduling) and interpretation-rule surfaces were checked for over-absorption and confirmed in-pattern (holding is a classification on the record, never a queue; interpretation is deployment configuration the constituent itself delegates). Pass 3 ran the adversarial question set and the three postures. Seven findings, all closed in-pattern:
- F1 — subsection order deviation — refining → the load-bearing wiring decision subsection preceded Action wiring, against
spec-format.md’s required order; reordered to Composition state → Configuration → Primitive policies → Action wiring → Replay semantics → load-bearing decision. - F2 — SMS unglossed — refining → first prose use of SMS in the disputed-delivery scenario now spells out Short Message Service.
- F3 — FCC and push-service acronyms unglossed — refining → “the FCC rule” rewritten as the Federal Communications Commission’s implementing rule; the inherited push-service initialisms replaced with plain words.
- F4 —
decided_atprovenance unstated — foundational → the spec nowhere said whose clock stamps the disposition events; the Clock semantics edge case now pins one host-injectednowper invocation (fired_atand everydecided_atcarry the same instant), names the bounded within-invocation staleness this buys, and names the transport-layer re-check as the mitigation where the bound is material. - F5 — empty default shape produces a lying suppression reason — refining → an empty declared default channel set would route every no-record deliver verdict into
suppress(channel-opt-out)with no opt-out on record; Configuration now requires the default shape’s channel set be non-empty and points the suppress-everything posture atno_record_policy = suppress. - F6 — gate input mis-typed as a single count — refining →
shaping_dispositionwas signed withrecent_delivery_countwhilefrequency_limit_interpretationmay yield several(window, cap)pairs; the input is the timestamp set (the index entry), per-pair counts computed inside the pure function; signature, step 5, andevaluation_inputsaligned. - F7 — channel-opt-out and suppress-mode no-record had no worked example — refining → marketing-newsletter example added exercising both under CAN-SPAM, including the they-said-no versus they-never-said distinction the records preserve.
Pass 1 / Pass 2 rerun after the fixes: clean — the reorder and glosses introduce no new references; the new example uses only declared configuration values; no new extraction candidate opened.
Final Critique 4 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-11 (AI-conducted round; claude-fable-5, fresh context: pass question sets, the spec body with Status and Lineage withheld, the constituent specs). The reviewer first re-verified every cross-reference head-on against the constituents (invariant counts, names, signatures, rejection vocabularies — all confirmed), then surfaced 22 findings: 6 foundational, 13 refining, 3 rhetorical. The gate did not close clean; all 22 were closed in-pattern in the same session, and the foundational six reshaped the action surface — the round earned its keep. Findings and fixes:
- Final Critique 7 — journal query capability undeclared — foundational → every rebuild, check, and adversarial query needed payload-predicate queries Event Log’s spec leaves to implementation policy (the capability-provenance class Immutable Transaction Ledger first surfaced). Fixed:
journal_query_capabilitydeclared as a named Configuration capability; Composition state, Invariant 4’s Rests on, and a new externally-clearable check all route through it. - Final Critique 11 — Message Preference-store failure unhandled on the load-bearing read — foundational → a failed
current_forread was indistinguishable fromnone, so an outage delivered unshaped to suspended principals. Fixed: step 5 fails closed —failed(preference-unreadable)(andaccounting-unreadablefor a failed cold-start rebuild); an unreadable shaping input never produces a deliver verdict. - Final Critique 12 — no journaled per-principal retry; the recommended recovery was indistinguishable from bypass — foundational → direct
Notification.createretries skipped the gate and produced the exact unjournaled artifact breach forensics flags. Fixed:redispose(fanout_id, principal_ref, payload_content)added — gate-re-evaluated, digest-bound, journaled under the originalfanout_id, rejecting already-delivered principals; every recovery path (failed retry,holdscheduler, crash gaps) now routes through it. - Final Critique 13 — gate verdict undefined under an undeclared interpretation — foundational → a record carrying
quiet_hoursin a deployment that never declared the interpretation had no specified outcome. Fixed: fail-closed rule in the Configuration preamble and step 5 —failed(interpretation-undeclared), never a silent deliver, never a fabricated suppression reason. - Final Critique 14 — single-pipeline routing obligation unnamed — foundational → deliveries around the gate (unshaped sibling, direct create) defeated the cap and quiet-window claims with no named obligation. Fixed: the single-pipeline obligation named as an Edge case (on the Propagate Consent Revocation Downstream processing-systems-must-use-the-gate precedent), an externally-clearable disclosure, and a third named-not-discharged obligation in the TCPA standards entry.
- Final Critique 15 — delivered events lacked the replay inputs Replay semantics claimed — foundational → deliver verdicts were unreplayable and best-effort overshoots undiagnosable. Fixed:
fanout.deliveredevents now carryevaluation_inputs(observed in-window counts per interpreted pair); Replay semantics, Invariant 7, and Checks 2/5 updated to consume them. - Final Critique 1 — consumed knobs missing from Configuration — refining →
payload_digest_functionandreconciliation_windowadded as knobs. Final Critique 2 —fanout_dispositionsremoval clause missing — refining → eviction-at-deployment-discretion clause added (every fact regenerates). Final Critique 3 — unresolvable body references to “the formal model” — refining → rephrased to name the formal layer with a Lineage §Formal model pointer. Final Critique 4 — phantom subscriber-accounting field in the breach scenario — refining → scenario now reconstructs the Active set atfired_atper Check 1. Final Critique 5 — Invariant 8 had no acceptance check — refining → Check 6 added (digest-anchored content consistency). Final Critique 8 — Event Log Invariant 6 mis-cited forfanout_iduniqueness — refining → Invariant 10’s Rests on corrected to the composition-introduced generation plus Event Log Invariants 1–2. Final Critique 9 — journal durability overstated — refining → Invariant 10 renamed to “unique and journaled”; store durability named as the deployment’s provisioning obligation, routed externally. Final Critique 16 — cap bound not anchored to the contemporaneous record — refining → Invariant 4 restated per-commit against the record observed at each disposition; Check 2 rewritten accordingly (a mid-window cap-loweringsetis not a retroactive violation). Final Critique 17 — single injectednowvs. later constituent reads unreconciled — refining → cross-store clock tolerance added to the disclosures; Checks 1 and 3 evaluate within it, with the recordedpreference_idauthoritative and boundary cases ambiguous-pending-evidence. Final Critique 18 —retry_eligibleundefined for three reasons — refining → totality rule in step 6:suspended/no-record/channel-opt-outare always false (they relent by principal action, not time). Final Critique 19 — Invariant 3 lacked Invariant 2’s observation anchor — refining → “as observed by the gate at disposition evaluation” propagated. Final Critique 20 — cap example arithmetic wrong under its natural reading — refining → rewritten with explicit Monday/Tuesday timestamps. Final Critique 21 — format default for a channels-without-format record unpinned — refining →channel_interpretationnow supplies the absent-dimension default. - Final Critique 6 — CFR / U.S.C. unglossed — rhetorical → both spelled out at first use. Final Critique 10 — quote attributed to Message Preference’s Behavior instead of Intent — rhetorical → attribution corrected. Final Critique 22 —
log-unavailablemislabeled the definitiveinvalid-payloadcase — rhetorical → renamedjournal-rejected, with the retryable-vs-definitive distinction stated where the rejection is defined.
Pass 1 / Pass 2 rerun after the fixes: clean — the new action, causes, knobs, and check are referenced in both directions (signature ↔ steps ↔ invariants ↔ checks ↔ disclosures); redispose is an emergent action in the expected sense (no constituent owns a gate-re-evaluated retry), not an over-absorption; no new extraction candidate opened (the retryability rule reads the journal; it carries no state).
Final Critique 5 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-11 (AI-conducted round; claude-fable-5, fresh context: pass question sets, the spec body with Status and Lineage withheld, the constituent specs). The reviewer re-verified the full cross-reference surface against the constituents (all counts, names, signatures, rejection vocabularies confirmed accurate) and surfaced 14 findings: 2 foundational, 9 refining, 3 rhetorical — the foundational pair both attacking the surfaces Final Critique 4’s fixes had introduced, which is the refinement loop working as designed. All closed in-pattern (one rhetorical accepted with rationale):
- Final Critique 5 — replay of status-dependent verdicts unsupported — foundational → the gate’s rule 1 turns on
status, the one field Message Preference Invariant 1 leaves mutable, soMessage Preference.readat replay time cannot recover what the gate saw. Fixed:evaluation_inputson both event types now record the observed record status; Replay semantics rewritten around the immutable-from-the-record / mutable-from-the-event split; Invariant 7’s Rests on corrected to value-field immutability. - Final Critique 5 —
redisposecheck-then-act race unserialized — foundational → two concurrent redisposes could both observe a retryable latest event and double-deliver under an invariant stated unconditionally. Fixed: step 3 now names the per-(fanout_id, principal_ref)serialization as a host conformance requirement (the Message Preference per-principal precedent); Invariant 9 cites it; the cross-store edge case enumerates it. - Final Critique 5 —
redisposejournal-rejectedunwired — refining → steps 2 and 4 now produce it (failed journal read; rejected disposition append, nothing committed). Final Critique 5 — clock capabilities cited to the wrong home — refining →clock_tolerancepromoted to a Configuration knob; Invariant 3’s citation corrected (the recipient-local capability is an attestation, declared in the externally-clearable checks). Final Critique 5 — query capability’s structural home unnamed — refining → the knob now names Event Log’s Reverse Index pattern (forthcoming) as the eventual constituent home. Final Critique 5 — replay-input type mismatch — refining → the gate’s compute-counts ∘ verdict factoring stated; replay re-enters at the verdict factor with recorded counts. Final Critique 5 — fail-closed missed the undeclared default shape — refining → the preamble rule now covers the no-record deliver path. Final Critique 5 —reconciliation_windowoptionality contradicted its consumers — refining → required for every deployment. Final Critique 5 — crash-gap admission unspecified — refining → step 3 now reconstructs per Check 1 and fails closed on tolerance-ambiguous membership. Final Critique 5 — cross-invocation accounting clock unstated — refining → Invariant 4 names the non-decreasing-to-within-clock_toleranceassumption and its deflation failure mode. Final Critique 5 — example gaps on the novel mechanisms — refining → fail-closed example added;not-known/payload-mismatchrejections exemplified; walkthrough configuration completed. - Final Critique 5 — non-verbatim Propagate Consent Revocation Downstream quotation — rhetorical → paraphrased to Propagate Consent Revocation Downstream’s actual gate discipline. Final Critique 5 — Summary “nothing is ever silently dropped” overclaim — rhetorical → restated with the crash-detection qualifier in plain language. Final Critique 5 — “Record-clearable checks” vs. the overlay’s “Audit-Trail-traversal-clearable checks” heading — rhetorical, accepted with rationale → this composition carries no Audit Trail substrate, so the overlay’s traversal name would claim a surface that is not wired; the sibling Notification Fanout’s “Record-clearable checks” is the established heading for the substrate-less case, and the load-bearing structure (the two-way split itself) is intact.
Pass 1 / Pass 2 rerun after the fixes: clean — the observed-status field, the serialization requirement, and the two new knobs are referenced from their consumers; no new extraction candidate (the serialization requirement is a host obligation, not state).
Final Critique 6 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-11 (AI-conducted round; claude-fable-5, fresh context as before). The reviewer confirmed the full cross-reference surface accurate and surfaced 13 findings: 3 foundational, 9 refining, 1 rhetorical. All closed in-pattern (one refining accepted with rationale):
- Final Critique 6 — gate not total for a record without
channel_preferences— foundational → afrequency_limit-only record reached rule v with no defined verdict. Fixed: rule v and thechannel_interpretationknob now apply the declared default shape wherever the deliver path has no stated channels (no-record deliver path and channel-less records). - Final Critique 6 — payload-retention obligation unnamed — foundational → every recovery path needs the digest-matching
payload_content, but an all-suppressed or zero-commit invocation leaves no in-system copy. Fixed: the retention obligation (keyed byfanout_id, held for the retry horizon) named in the payload edge case and the externally-clearable disclosures. - Final Critique 6 — statutory quiet windows owned by no one — foundational → the gate fired only on stored
quiet_hours, while the TCPA’s 8am–9pm presumption is not preference-conditional. Fixed:statutory_quiet_windowConfiguration knob (window + channel set, evaluated for every disposition); rule v excludes statutory-blocked channels and suppresses asquiet-windowwhen the exclusion empties the set; Invariant 3 gains the statutory arm; the TCPA standards entry and the disputed-delivery scenario rewritten accordingly. - Final Critique 6 — Subscription equality declaration overstated — refining → the byte-exact rule restated as this composition’s own declared policy, conservative against both constituents. Final Critique 6 — channel-scoped caps unimplementable from the declared inputs — refining →
delivery_count_indexentries andfanout.deliveredevents now carry(decided_at, channels); the gate’s history input and rule iv updated. Final Critique 6 —redisposestep 4 conflicted with “exactly as there” — refining → the journal-rejection deviation is now named as step 4’s own rule. Final Critique 6 — fail-closed “field” unscoped — refining → scoped to the four interpreted preference fields;metadataexcluded explicitly. Final Critique 6 — false analogy defendingjournal-rejected— refining → defense rewritten on its real ground (one invocation-level taxonomy; the distinction available by retry or Event Log telemetry). Final Critique 6 — “exactly once per invocation” contradicted byredispose— refining → Intent and Mechanism now say once per disposition. Final Critique 6 —redisposelacked afanout_idprimitive policy — refining → step 1 validates it. Final Critique 6 — ambiguous crash-gap subscriber had no terminal — refining →fanout.abandonedadded as the journaled terminal discharging the liveness arm; Check 1 evaluated modulo it. Final Critique 6 — “Record-clearable checks” vs. the overlay’s traversal-named container — refining, accepted with rationale → this composition carries no Audit Trail substrate, so the traversal name would claim an unwired surface; the substrate-less sibling (Notification Fanout, grounded) is the heading precedent; flagged below as a library-wide container-naming question for spec-format rather than a per-pattern fix. - Final Critique 6 — body cited its withheld Lineage as authority — rhetorical → Composition state now stands on the rebuild procedures alone.
Pass 1 / Pass 2 rerun after the fixes: clean. Library-wide item surfaced: spec-format’s Generation-acceptance split names its first container “Audit-Trail-traversal-clearable checks,” which mis-fits compositions with no Audit Trail substrate (Notification Fanout and now this composition both use “Record-clearable checks”); the container-naming rule deserves a substrate-less variant in spec-format’s next round.
Final Critique 7 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-11 (AI-conducted round; claude-fable-5, fresh context as before; the Final Critique 6 accepted-deviation noted to the reviewer as settled). 14 findings: 3 foundational, 9 refining, 2 rhetorical. All closed in-pattern. The headline fix — journaling the queried list — closed one foundational and two refining findings at once:
- Final Critique 7 — crash-gap path read Subscription’s audit surface at runtime — foundational →
redispose’s reconstruction violated the atom interface contract (Subscription’s historical filter lives on its audit surface, not its runtime Q surface) and contradicted Composes and Invariant 6. Fixed:fanout.initiatednow journals the exactqueriedlist; runtime retryability reads the journal only; the Subscription-store historical reconstruction moves to Check 1 as an auditor-side cross-check, where the audit surface is the legitimate instrument. - Final Critique 7 — replay had no configuration-contemporaneity rule — foundational → a legitimate interpretation change between disposition and replay produced divergence the trichotomy misattributed. Fixed: the interpretation configuration is versioned-never-mutated (Configuration preamble, on Message Preference’s instance-configuration-record precedent);
config_versionjournaled per invocation; Replay semantics, Check 2, and the walkthrough evaluate under the journaled version. - Final Critique 7 — overshoot “reconciliation evidence” demanded but undefined — foundational → the cap liveness arm had no detector, actor, or record. Fixed: Invariant 4 now defines the detector (scan delivery events per principal under each journaled version’s windows) and the record (
fanout.cap-overshoot-reconciled), run by the same reconciliation as journal gaps; Check 2 consumes the record. - Final Critique 7 — SMS glossed late — refining → glossed at its new first use (the statutory knob). Final Critique 7 — gate definition referenced one-directionally — refining → step 5 now names §The load-bearing wiring decision (Mechanism). Final Critique 7 — constituent-store retention unpropagated — refining → the durability disclosure now covers Subscription and Message Preference store retention across the audit horizon. Final Critique 7 —
fired_at-vs-query gap uncovered byclock_tolerance— refining → resolved structurally by the journaledqueriedlist (no runtime reconstruction); the cross-check tolerance language survives only on the auditor side. Final Critique 7 — walkthrough omitted the statutory window its channels require — refining → walkthrough declares it (and the knob’s requirement collapsed to one rule: required where deliveries on a statutorily restricted channel fall within the restriction’s scope). Final Critique 7 — abandonment terminality unenforced — refining →fanout.abandonedgained a principal scope;redisposerejects covered principals. Final Critique 7 —reconciliation_windowhad no setting rule — refining → rule added (no longer than the audit regime’s reporting period; a never-binding window is a vacuous declaration). Final Critique 7 — serialized arm vs. the index’s no-consistency posture — refining →serialized-per-principalnow explicitly obligates rebuild-before-evaluation. Final Critique 7 — failure half of Invariant 5 unchecked — refining → Check 3 extended to the closed cause set. - Final Critique 7 — retry-eligibility rationale invited the wrong inference — rhetorical → terminal-for-this-invocation stated outright. Final Critique 7 —
fanout.deliverednamed a create-commit with a transport word — rhetorical → events renamedfanout.created/fanout.create-failed, ending the collision with Notification’s transport-level Delivered state.
Pass 1 / Pass 2 rerun after the fixes: clean — the queried and config_version fields, the overshoot record, and the renames are referenced from their consumers; the versioned-configuration discipline is deployment-owned vocabulary (Message Preference’s precedent), not a new concept to extract.
Final Critique 8 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-12 (AI-conducted round; claude-fable-5, fresh context as before; the Final Critique 6 accepted-deviation noted as settled). Pass 2 returned fully clean for the first time. 10 findings: 2 foundational, 7 refining, 1 rhetorical — both foundational attacking the retry surface Final Critique 5–7 had built. All closed in-pattern:
- Final Critique 8 — transport-failure retry structurally foreclosed — foundational → Notification’s retry model (a new
createper attempt) had no conformant per-principal path: direct creates are branded bypass, andredisposerejected any already-created principal. Fixed:redisposestep 3 gained the transport-failure path — afanout.createdprincipal is retryable whenNotification.status_ofshows the record reachedfailedorexpired— so the re-send re-passes the gate under current rules; Invariant 9 restated as at-most-one-live (Pending or Delivered) record with the rule’s Rests on citing Notification Invariants 2–4; the Transport edge case names the path and the bypass consequence; examples exercise it. - Final Critique 8 —
redisposenever re-checked Subscription across its unbounded horizon — foundational → a principal who cancelled between the invocation and a days-laterholdretry was still delivered to. Fixed:redisposestep 4 re-checksSubscription.subscribed(the declared point query) before the gate, committing a journaledsuppress(unsubscribed)on a cancelled audience member; the staleness edge case now defends the asymmetry (bounded intra-invocation window accepted; unbounded redispose horizon re-verified); the closed reason set,retry_eligibletotality, Check 3, Composes, and Invariant 6 all extended. - Final Critique 8 — Primitive policies missed
redispose’s inputs — refining →fanout_idbullet added; preamble covers both actions. Final Critique 8 — disclosure list incomplete against Configuration — refining →statutory_quiet_window(or its omission-as-posture) and the version-retention obligation added. Final Critique 8 — statutory-emptied no-record suppression contradicted Check 3’spreference_idrule — refining → the rule restated principle-first:noneexactly when the gate observed no in-effect record (or never reached the gate, onunsubscribed). Final Critique 8 — Check 2’s serialized clause ambiguous under a mid-window cap change — refining → rewritten as an observation-vs-history walk (each event’s recorded count equals the recount from preceding events; each below its own contemporaneous cap — never a recount against the current record). Final Critique 8 — Check 4 contradicted the crash-gap repair — refining → the no-prior-event repair case named in the check. Final Critique 8 —clock_toleranceconflated two clock disciplines — refining → split into components (a) cross-store skew and (b) accounting-clock regression, with consumers re-pointed. Final Critique 8 — step 6 omitted the statutory-exclusion record step 5.v promised — refining → added to the deliver-pathevaluation_inputsenumeration. - Final Critique 8 — “deliberately overrides” the derived-index posture — rhetorical → restated as conformance: the serialized path performs the rebuild inside the serialization boundary, which is exactly the guarantee a rebuild can honor.
Pass 1 / Pass 2 rerun after the fixes: clean — subscribed and status_of are declared constituent queries (capability provenance intact); the unsubscribed reason and the transport-retry path are referenced from signature to checks; no new extraction candidate (the retry adjudication reads constituent stores through declared surfaces and carries no state).
Final Critique 9 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-12 (AI-conducted round; claude-fable-5, fresh context as before; the Final Critique 6 accepted-deviation noted as settled). GATE CLEAN FOR GROUNDING — zero foundational findings. Pass 1 surfaced one rhetorical item; Pass 2 returned clean for the second consecutive round, with the reviewer explicitly confirming all ten invariants’ capability provenance, the extraction-gate verdicts, and that the serialized-per-principal rebuild defense holds against the derived-index rule’s three obligations. Pass 3 at X2 surfaced 13 findings: 0 foundational, 8 refining, 5 rhetorical — under the 92%-good threshold, the grounding-determinative result. All 13 closed in-pattern in the same session:
- Final Critique 9 — replay quantifier covered the unrendered
unsubscribedverdict — refining → Invariant 7, Replay semantics, and Check 5 now scope to gate-rendered dispositions; anunsubscribedevent’s replay is its recorded point-query outcome. Final Critique 9 — Check 3 demanded a gate observationunsubscribedevents never make — refining → step 4 now journals the audience re-check outcome inevaluation_inputs; Check 3’snone-rule restated to match. Final Critique 9 — abandonment terminality scoped only to the crash-gap path — refining →redisposestep 3 now opens with the abandonment terminal, applied on every path. Final Critique 9 — authorization toredisposeunstated — refining → the authorization edge case covers both actions, names the digest bar as integrity-not-authorization, and flagsredisposeas the surface to gate first. Final Critique 9 — invocation-duration bound not disclosed — refining → added to the externally-clearable disclosures. Final Critique 9 — fan-out-at-scale edge case silently dropped from the sibling’s set — refining → restored, sharpened to this composition’s own scale surfaces (journaledqueriedlist vs. the payload cap;now-staleness vs. loop length; per-principal-only serialization). Final Critique 9 — statutory arm never exercised by an example — refining → the finn example added (no stored window; the statutory exclusion empties the set; delivery after the window viaredispose). Final Critique 9 —journal_query_capability’s consumer list omitted the action path — refining →redispose’s runtime journal reads enumerated. - Final Critique 9 — Notification Invariant 2 cited but unglossed — rhetorical → gloss completed (status monotonicity is what makes the transport adjudication stable). Final Critique 9 — walkthrough’s single-number
clock_toleranceunqualified — rhetorical → the both-components attestation stated. Final Critique 9 — “in-memory” over-pinned the index’s locale — rhetorical → dropped. Final Critique 9 — “advisory … must treat it as binding” self-contradiction — rhetorical → unpacked (the composition advises; the deployment’s obligation makes it binding). Final Critique 9 — Check 2’s duplicated anchor clause — rhetorical → deduplicated.
Pass 1 / Pass 2 rerun after the fixes: clean. The pattern grounds on Final Critique 9 with the formal layer already discharged (see §Formal model above) — refining and rhetorical findings closed, foundational count zero across all three passes.
Formal model — 2026-06-12: TLA+ authored and verified. Derived model preference-aware-notification-fanout.tla + config preference-aware-notification-fanout.cfg, checked by tla-checker via tools/harness/check.mjs. What it checks: the load-bearing Invariant 4 (frequency-cap safety, safety arm) under cap_serialization = serialized-per-principal — one principal, one interpreted (window, cap) pair, Workers concurrent fanout invocations each rendering at most one disposition; the correct gate is a single atomic observe-and-commit (GateDeliver) alongside the cap-suppression branch (GateSuppress, which makes the cap guard non-vacuous: once the cap is met, suppression is the only enabled disposition). The committed delivery count never exceeds the cap (Inv4_CapSafety) and never goes negative. The delivery_count_index is deliberately not modeled as separate state — per the Execution Contract’s derived-index rule, the model covers the truth-bearing store (the journaled count) and omits the index. Exhaustive: 10 states at Cap = 2, Workers = 3, holds; bound bump to Workers = 4 → 29 states, exhaustive, still holds (saturation recorded in the config). Buggy twin preference-aware-notification-fanout-buggy.tla splits the gate into GateObserve / GateCommit with no re-check at commit — the deployment that declared serialization but implemented read-then-commit; two workers both observe headroom on the last slot and both commit, overshooting the cap; rejected by the checker (Inv4_CapSafety violated, 30 states), with Inv_NonNegative still holding so the violation is isolated to the cap bound — the twin shape mirrors capacity-constraint-enforcement-buggy-toctou.tla exactly, as the kickoff adjudication predicted. Scope exclusions, named: quiet windows / channel selection / suspended paths (per-record predicates, no cross-invocation race); the suspend-vs-fanout interleaving (no stated invariant to violate — see the formal-layer vote above); redispose’s per-(fanout_id, principal_ref) serialization (the identical guard shape at a different key). Conflict-protocol outcome: none — the model corroborates the English; canonical English unchanged.
Final Critique 10 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-12 (AI-conducted post-grounding rescan; fresh context: pass question sets, the spec body with Status and Lineage withheld, the constituent specs). GATE REOPENED — one foundational surfaced after Final Critique 9 grounded. The reader re-verified the cross-reference surface (all counts, names, signatures, rejection vocabularies confirmed) and surfaced 6 findings: 1 foundational, 2 refining, 3 rhetorical, all closed in-pattern:
- fail-closed preamble overrode the gate’s own precedence short-circuit — foundational → step 5’s input-specific fail-closed checks (
accounting-unreadable,interpretation-undeclared) were evaluated beforeshaping_disposition’s precedence, so a Suspended (or quiet-window-eligible) principal carryingfrequency_limitwas mis-dispositionedfailed(accounting-unreadable)during an Event Log read outage — contradicting the declared precedence and Invariant 2’s “first rule that fired” (no safety invariant breached; the defect is mis-classification: a retry-eligible failure where a terminal suppression was owed). Fixed:preference-unreadableremains the one pre-gate guard (the record gates every rule);accounting-unreadable/interpretation-undeclareddeferred into the precedence walk and raised at the consuming rule (iii / iv / v);shaping_dispositiongains therecent_delivery_history | unavailableinput and thefail(accounting-unreadable | interpretation-undeclared)codomain; rules ii–v, Mechanism, the Configuration preamble, step 6, and the outage example aligned. Disposition correctness is now independent of host input-assembly order (the gate short-circuits at rule i regardless of whether the host eagerly attempted the rebuild). - per-principal cap serialization’s applicability to
redisposeuncross-referenced — refining → thecap_serializationknob andredisposestep 4 now state the cap path serializes wherever it runs (both actions), andredispose’s per-(fanout_id, principal_ref)serialization does not subsume it. reconciliation-run obligation and abandonment-write authority unenumerated — refining → an externally-clearable “Reconciliation discipline” disclosure was added (superseded by Final Critique 11’s surface). “fall through to deliver” read as bypassing the statutory exclusion — rhetorical → rule 5.ii now falls through to channel selection (rule v). misplaced parenthetical inredisposestep 3 — rhetorical → reworded. “delivered event” vs the type namefanout.created— rhetorical → namedfanout.created(deliver-verdict) in step 6 and Invariant 4.
Refinement re-pass (Pass 1 → 2 → 3 after the fixes) surfaced one follow-on, closed: Invariant 7’s “the gate rendered” quantifier swept in the new fail(...) codomain — refining → Invariant 7 now scopes to gate-rendered deliver-or-suppress dispositions and excludes the fail verdicts (journaled as fanout.create-failed), parallel to the unsubscribed carve-out (Final Critique 9 precedent). Pass 2 clean — the unavailable marker and the fail codomain are pure-function inputs/outputs, not new state.
Final Critique 11 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-12 (AI-conducted, fresh context as before). GATE NOT CLEAN — reconciliation-surface provenance cluster. A second fresh reader surfaced 10 findings: 4 foundational (claimed), 4 refining, 2 rhetorical. Adjudication trimmed the foundational cluster: the central valid defect was that the two reconciliation event types (fanout.abandoned, fanout.cap-overshoot-reconciled) were written by anonymous “deployment reconciliation” with no capability-provenance home (R-2 / R-9), while R-3 (“fanout.abandoned is unclassified composition state”) was an over-claim — the record lives in Event Log, the sanctioned history store, so it is not composition-carried state, and R-8 was refining-foundational-adjacent. Resolution (adjudicated, owner’s choice: a composition-introduced surface over a lighter declared-capability framing): introduce the composition-introduced reconciliation surface reconcile_gaps / reconcile_overshoots that owns both writes, reads the journal under journal_query_capability, computes pure detectors, appends via EventLog.append, and carries no composition state (records live in Event Log) — re-pointing Invariant 4’s liveness arm, the invocation→dispositions relation, the crash-reconciliation edge case, and the externally-clearable disclosure to it. The action surface is now three surfaces, not two. Other fixes: R-4 — preference-unreadable gate-verdict ambiguity — refining → step 5 restructured into one pre-gate guard plus two in-gate verdicts. R-5 — redispose journal-rejected “nothing committed” vs the irreversible Notification.create — refining → the deliver-pair rollback now explicitly leans on the cross-store multi-write atomicity boundary. R-6 — no-record deliver-unshaped vs an undeclared default shape — refining → rule ii and the no_record_policy knob now pin that deliver-unshaped requires a declared default shape, else fail-closed, never a silent deliver. R-8 — the invocation-duration bound is load-bearing for Invariant 3 but was not a knob — refining → promoted to a declared invocation_duration_bound Configuration knob with a setting rule; Invariant 3’s Rests on, Clock semantics, the walkthrough config, and the disclosures re-pointed.
Final Critique 12 — fresh-reader Phase 3 + clearance gate, X2 on Pass 3 — 2026-06-12 (AI-conducted, fresh context as before). GATE CLEAN FOR GROUNDING — zero foundational findings. A third fresh reader confirmed all nine GRID nodes, every constituent invariant citation by name and number, capability provenance for every Rests on: line, both derived-index classifications, and — the Final Critique 11 subject — that the reconciliation surface carries no composition state and its writes are consistently owned and referenced. 5 findings: 0 foundational, 3 refining, 2 rhetorical, all closed in-pattern (one rhetorical accepted with rationale):
- Invariant 2’s enumeration omitted
unsubscribed— refining → the deliberate exclusion noted in-line (unsubscribedis aredispose-only audience-recheck outcome, not a gate verdict against an Active subscriber).redispose’sstatus_ofread-then-create window, and the two-axis serialization, stated thinly across three sites — refining / rhetorical → consolidated into the Cross-store edge case: per-(fanout_id, principal_ref)closes thestatus_of→createwindow (Invariant 9), per-principal closes the cap window (cap_serialization), and aredisposedeliver serializes on both. the best-effort cap-overshoot hazard (the spec’s self-named load-bearing race) had no worked example — refining → the fred example added: two concurrent invocations both observe count = cap − 1, both deliver, the per-commit anchor still holds, andreconcile_overshootswrites thefanout.cap-overshoot-reconciledrecord Check 2 reads. - blockquote “frequency caps suppress” overstates against the per-commit best-effort conditional — rhetorical, accepted with rationale → the Tier-1 blockquote legitimately simplifies; the precise conditional (hard under
serialized-per-principal; bounded detectable overshoot otherwise) is carried in Invariant 4, thecap_serializationknob, and now the fred example.
Pass 1 / Pass 2 rerun after the fixes: clean. The pattern re-grounds on Final Critique 12 — the Final Critique 10 gate-ordering defect and the Final Critique 11 reconciliation-provenance cluster closed, foundational count zero across all three passes.
Formal model — coverage note, 2026-06-12 (post-Final Critique 12). The Final Critique 10–12 changes do not touch the cap-TOCTOU race the TLA+ model verifies (Invariant 4’s atomic observe-and-commit under serialized-per-principal, and the split-observe/commit buggy twin). The gate fail-closed reordering is about per-record unreadable/undeclared inputs, not the cross-invocation cap race; the reconciliation surface (reconcile_overshoots) formalizes the liveness-arm writer, which the model already lists as out-of-scope (a best-effort liveness property, not the modeled safety race); and the cap-serialization findings reinforce the very serialized atomic-observe-and-commit assumption the correct model encodes. The model continues to corroborate the English; no re-derivation required, coverage matrix unchanged.
Prose-simplification pass — 2026-06-26 (first live exercise of working-ideas/prose.md). A density-not-depth edit confined to Action wiring step 5. The shaping_disposition narration — the ~120-word fail-closed lead sentence plus the five-rule sub-list — was converted to a precedence decision table (rules i–v, top-to-bottom first-match, recorded reason = the first rule that fired) with the three semantics a table cell cannot hold kept as prose beside it: the pre-gate preference-unreadable guard (raised before the gate, deliberately not a row), rule v’s empty-set disambiguation (channel-opt-out vs. quiet-window), and the observation anchor (the verdict is against the record as observed; a later set / suspend is the named staleness window, not a violation). Expression only — the precedence, the per-rule fail-closed raising (now a table column instead of buried mid-sentence), the input assembly, the configuration knobs, and every Rests on: line are unchanged in force. The table sits at the section’s top level and the action-wiring list resumes at step 6 (the corpus uses no nested tables). Re-verified, not re-grounded: a prose render change is not a fresh critique round, so the Status token stands at Final Critique 12. Depth-survival gates run in order: the diff read line-by-line against the same-claim-or-weaker-claim test; the linter clean (0 findings); the TLA+ model and its buggy twin untouched and still PASS / correctly-rejected (a prose-only edit leaves the English-to-formal SSOT contract intact); and an adversarial fresh reader, given only the rewritten gate text plus the invariant list, found Invariants 2, 3, 4, and 7 each still derivable from the new text — Invariant 4’s per-commit anchor and best-effort race live in the Invariant 4 statement, not the gate, which owes only suppress at count ≥ cap. This discharges the live-prose exercise that working-ideas/prose.md’s promotion gate named as its last bar before promotion into spec-format.md.
Summary/blockquote merge — 2026-06-26 (piloted spec-format deviation). The required title blockquote and the ## Summary section were merged into a single plain-language ## Summary at the top, ahead of Intent — a deliberate deviation from spec-format.md’s required-section order (blockquote → Intent → Summary), piloted on this pattern pending a corpus-wide decision. With the Summary trimmed to its mandated few sentences the two were redundant here; the merged section keeps the jargon-free Tier-1 register (the library’s bridge to non-engineers) and folds in the blockquote’s what-it-composes and emergent-guarantee content. No unique content was lost: the jargon-allowed technical framing — widening the bipartition {created, failed} to the trichotomy {created, failed, suppressed} — already lives in Intent, and the terse pitch survives in the compositions catalog entry. If ratified, the change belongs in spec-format.md §Required sections and rolls to the other patterns.
Showcase pass — 2026-06-29. Representational-only annotation/legibility pass; no guarantee, invariant, number, formula, signature, or rejection taxonomy changed (the invariant count held at ten, and the three action surfaces at three). This pattern had already received the 2026-06-26 Summary/blockquote merge and prose-simplification (the Shaping Disposition gate narration → the rules i–v precedence table) passes recorded above, and its Lineage was already collapsed into a <details> block; this pass adds only the remaining showcase element. (a) Four-kind [Term] annotation applied across the body and a ## Terms registry added after Edge cases (18 terms): 5 Operations — the three exposed action surfaces (Fanout Shaped, Redispose, and the reconciliation pair Reconcile Gaps / Reconcile Overshoots) plus the composition-introduced pure gate they run (Shaping Disposition); 3 Fields — the correlation handle (Fanout Id) and two journaled gate data (Retry Eligible, Evaluation Inputs); and 10 Members — the closed six-reason suppression vocabulary the gate renders (Suspended, Quiet Window, Frequency Cap, No Record, Channel Opt Out, and the redispose-only Unsubscribed), the three fail-closed causes it introduces (Preference Unreadable, Accounting Unreadable, Interpretation Undeclared), and its own invocation-level rejection (Journal Rejected). Every own-action prose reference is linked; signatures and example calls, which carry parentheses, stay backticked. The suppression-reason and fail-cause Members are enum values, so their concept references are linked at representative homes (the precedence-table rule names and the naming prose) while their wire forms stay backticked. No Type card: the composition owns no record store — its state (delivery_count_index, fanout_dispositions) is derived-index over the four constituent stores, left as backticked tokens per the Execution-Contract Composition-state rule. No Parameter: every action input (event_scope, payload_content, principal_ref, fanout_id as input) is a relayed constituent concept. Survivors left backticked: the projected-contract signatures and example calls; the disposition-list names (created / failed / suppressed) and the six fanout.* journal event types; every qualified constituent call (Subscription’s subscribers_for / subscribed, Notification’s create / status_of, Message Preference’s current_for / read, Event Log’s append / read) and their outcomes; the interpretation and policy knobs; the relayed constituent tokens; 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). (b) Summary/blockquote merge — already done 2026-06-26 (recorded above); the ## Summary sits at the top after the TOC, ahead of Intent, and no descriptive top blockquote remains to fold. (c) Lineage — already collapsed into a <details markdown="block"> block (this entry is appended as its last item). (d) prose cut #5 — already performed 2026-06-26 (the gate narration condensed into the precedence decision table, recorded above). Re-verified, not re-grounded: Status stays at grounded on Final Critique 12 — 2026-06-12. Gates: lint clean (O-term resolver — every marker resolves and every card is used); term-adapter derives cleanly (18 terms); ten composition-level invariants preserved; the three action surfaces intact; the .tla models untouched — harness re-run green: preference-aware-notification-fanout.tla PASS + preference-aware-notification-fanout-buggy.tla --buggy rejected on Inv4_CapSafety.