Preference-Aware Notification Fanout
Table of contents
- Preference-Aware Notification Fanout
- Intent
- Summary
- 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
- Standards references
- Status
- Lineage notes
A composition: when an event fires against a named scope, every currently-Active subscriber for that scope receives exactly one recorded disposition — a Notification record shaped to their stated delivery preferences, a named delivery failure, or a classified suppression. Composes Subscription, Notification, Preference, and Event Log directly. The preference-shaped superset of Notification Fanout: the same query-then-create loop, with a shaping gate between the subscriber query and each create that consults the subscriber’s in-effect Preference record — suspensions suppress, quiet hours suppress, frequency caps suppress, channel choices route — and every suppression lands in the Event Log with its reason and the preference record that produced it.
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 Preference atom records the answers — channels, frequency limits, quiet hours, format — but by its own declaration it does not deliver, does not consult subscriptions, and does not interpret its own opaque values: “the composing fanout pattern” is named in Preference’s text 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” is a fanout loop that 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, every subscriber passes through it on every disposition — the original invocation’s pass and any later journaled retry — and the gate’s 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 Reservation Lifecycle (C9), 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 — and 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 Preference’s own blockquote draws that line. A deployment whose deliveries require lawful basis sequences the Consent gate before invoking this composition; Consent and Consent & Preference Management (C2) 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.
Summary
Preference-Aware Notification Fanout connects an event to everyone who wants to hear about it — delivered the way each of them asked. It combines four simpler patterns: one that records who is interested in which kind of event (Subscription), one that records each person’s delivery wishes — preferred channels, “no more than N a day”, “nothing between 10pm and 7am”, plain text versus rich format (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, the composition asks who is subscribed, then — for each subscriber — reads their current preferences and decides: deliver on these channels in this format, or do not deliver, for a named reason (their preferences are paused, it is inside their quiet hours, they have hit their frequency limit, or they opted out of every channel). Every subscriber gets exactly one of three recorded outcomes — delivered, failed, or suppressed-with-reason — and nothing is silently dropped: each suppression is written to the journal with the reason and the preference record that caused it, and even a crash that interrupts a fanout mid-way leaves a journal trail from which the gap is detected and repaired. That journal entry is what lets the deployment prove, from records alone, that it honored quiet hours and frequency limits — the proof US telemarketing law (TCPA) and commercial-email law (CAN-SPAM) effectively demand. The composition does not decide whether contacting the person is legal at all (a separate consent pattern owns that, and runs first), and it does not transport anything — it produces the shaped delivery records a transport layer consumes.
Composes
- Subscription — provides the Active subscriber set via the
subscribers_for(event_scope)query, and the point querysubscribed(subscriber_ref, event_scope)thatredisposeuses 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 thatredisposereads 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. - 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 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 / Consent & Preference Management (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: theshaping_dispositionevaluation (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 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 — 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 Preference’s instance configuration records establish for its channel set (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 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 whenPreference.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 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 originalfanout_shapedpass and everyredisposere-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 (redisposesteps 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 singlefanout_shapedinvocation’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 (Short Message Service — text messaging); 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, 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 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(asredisposeinput) — 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(redisposesteps 1–2).principal_refandpayload_contentcarry the same rules onredisposeas onfanout_shaped.principal_ref/subscriber_refequality — the composition equates Subscription’ssubscriber_refand Preference’sprincipal_refbyte-exactly, with no normalization. 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. ReadPreference.current_for(subscriber_ref)→record | none. Fail-closed discipline — one pre-gate guard, then two in-gate verdicts. First the pre-gate guard, evaluated beforeshaping_dispositionis invoked at all: if the Preference read itself fails (infrastructure failure — distinct from a successful read returningnone), no rule can be evaluated because the record gates every rule, so the subscriber’s disposition is failed with causepreference-unreadable. This is the orchestration’s guard, not a gate verdict — it never reachesshaping_disposition(which is whypreference-unreadableis absent from the gate’s codomain), it never routes throughno_record_policy, and an outage of the preference store must never deliver unshaped to suspended and quiet-houred principals. Then the gate’s own two in-gate fail verdicts, raised at the precedence point where each input is first consulted — so a higher-precedence suppression that does not depend on it is rendered first and the recorded reason remains the first rule that fired (Invariant 2) — firing only if no earlier rule has already suppressed:interpretation-undeclared(the record carries a field whose interpretation the deployment never declared, Configuration preamble) is raised at the rule that would consult that field — rule iii forquiet_hours, rule iv forfrequency_limit, rule v forchannel_preferences/format/ the no-record default shape;accounting-unreadable(a record carryingfrequency_limitwhosedelivery_count_indexentry is unavailable and whose cold-start rebuild read against the Event Log fails) is raised at rule iv. A Suspended record therefore suppresses assuspended(rule i) without ever consulting the count or any interpretation, even during an Event Log outage; no fail-closed cause ever yields a deliver verdict. Otherwise, assemble the evaluation inputs: the record (or its absence), the injectednow, the principal’s recent delivery history fromdelivery_count_index— supplied as the(decided_at, channels)set, or as an explicit unavailable marker when the index misses and the cold-start rebuild read fails (the marker is consulted only if rule iv is reached, per above) — and the Configuration’s interpretation rules (each carrying, per field, whether its interpretation is declared). Evaluateshaping_disposition— the composition-introduced pure gate whose signature, inputs, and purity contract are defined in §The load-bearing wiring decision (Mechanism) — which renders the verdict by the declared precedence order: earlier rules win, and evaluation stops at the first suppression:- Suspended: the in-effect record’s status is
suspended→suppress(suspended). A Suspended record suppresses even though the Subscription is Active — pause means pause (Preference Invariant 6 guarantees the paused values are intact; this gate is what makes the pause operative). - No record:
current_forreturnednone→ perno_record_policy:suppress(no-record), or fall through to channel selection (rule v) with the declared default shape — where thestatutory_quiet_windowexclusion still applies, so a no-record principal can still suppress asquiet-window. Thedeliver-unshapedbranch presupposes a declared default shape; a deployment that setsdeliver-unshapedwithout one hits rule v’s fail-closed (fail(interpretation-undeclared)) — a surfaced configuration nonconformance, never a silent deliver. - Quiet window: the record carries
quiet_hours→ ifquiet_hours_interpretationis undeclared,fail(interpretation-undeclared); else ifquiet_hours_interpretation(quiet_hours, now)is inside →suppress(quiet-window). - Frequency cap: the record carries
frequency_limit→ iffrequency_limit_interpretationis undeclared,fail(interpretation-undeclared); else if the delivery history is the unavailable marker (index miss plus failed cold-start rebuild),fail(accounting-unreadable); else, for any interpreted(window, cap)pair (channel-scoped pairs counting only history entries naming that channel), the count of recent delivery-history entries falling inside the window meets or exceeds the cap →suppress(frequency-cap). - Channel selection: if the
channel_interpretation(or, on a path that needs it, the declared default shape) is undeclared,fail(interpretation-undeclared); otherwisechannel_interpretationmaps the record’schannel_preferences— or, where the deliver path has no stated channels (the no-record deliver path, and a record that carries other preference fields but nochannel_preferences; absence is no-preference, so the deployment default fills the channel dimension), the declared default shape — to the deliverable channel set and format. Channels inside a declaredstatutory_quiet_windowcontaining the injectednoware then excluded from the set (Configuration; the statutory exclusion is recorded inevaluation_inputs). An empty resulting set →suppress(channel-opt-out)if emptied by preference values,suppress(quiet-window)if emptied by the statutory exclusion. Otherwise →deliver(channels, format).
A rule whose input is absent is skipped (a record with no
quiet_hoursevaluates no quiet window — absence is no-preference, per Preference’s Behavior). 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. - Suspended: the in-effect record’s status is
- 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_inputshere 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 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 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_eligibleis 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 onlyredisposeproduces — 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 concurrentredisposecalls and against the original invocation’s disposition commit for the same principal — a host conformance requirement of the same kind as 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 offanout_shapedfor this one principal — a fresh host-injectednow, a freshPreference.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, soredisposenever 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: Preference.read(preference_id) returns the value fields (channel_preferences, frequency_limit, quiet_hours, format) exactly as the gate read them (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. 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 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 (redisposeappends 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 viaredisposeor terminally discharged by a journaledfanout.abandonedrecord written by the composition’sreconcile_gapssurface — 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 (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 viaPreference.readto a record that was in effect atdecided_at(reconstructible by Preference’s Generation acceptance Check 2). - Invariant 1 — Disposition trichotomy. For any
fanout_shapedinvocation that returns a result (notrejected), 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 aredispose-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: Preference Invariant 3 (at most one in-effect record) and Invariant 7 (current_fordeterminism); the composition-introducedshaping_dispositiongate; 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: 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’sreconcile_overshootssurface (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; thejournal-rejectedrejection 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 Preference store (current_for/readonly), and writes the Notification store only throughcreate(status_ofis its only other Notification call). All Subscription invariants, all 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_dispositionis a pure function of its recorded inputs: for every journaled deliver-or-suppress disposition the gate rendered (unsubscribedevents recordredispose’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 viaPreference.read, injectednow, the observed in-window counts fromevaluation_inputs, declared interpretations) reproduces the recorded verdict, per Replay semantics. Rests on: 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 laterredispose— carries the samecontentelement in its envelope: thepayload_contentwhose digest the invocation’sfanout.initiatedentry recorded (redisposeverifies 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 plusredisposestep 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.
redisposepreserves 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 ofredisposestep 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;redisposestep 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_idno other invocation shares, journaled infanout.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 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_idviaPreference.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 (Short Message Service — text message). The investigator reconstructs from records: the principal’s in-effect preference record at the delivery moment (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 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 viaPreference.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-evaluateshaping_dispositionfrom 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_overshootssurfaces (Action wiring) within the declaredreconciliation_window; those surfaces — not the deployment’s own code, and notfanout_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 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 directNotification.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 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), sinceredisposerequires 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_shapedis invoked (or as a gate wrapping it). A deliver verdict here is not legal permission; 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. Consent & Preference Management (C2) 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. - 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 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_gapscallsredispose(fanout_id, principal_ref, payload_content)for each (the named crash-gap exception inredisposestep 3: a subscriber in the reconstructed queried set with no disposition event is retryable), and for any residue it cannot resolve — a principal whose membership stays ambiguous within theclock_tolerance, or an invocation the deployment chooses not to repair — 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 —redisposestep 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-invokingfanout_shapedwholesale 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 throughfanout_shaped/redispose— the same single-gate discipline C2 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 throughredispose(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 throughredispose, 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 invokeredisposefor any journaled invocation.redisposedoes require the digest-matchingpayload_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 — andredispose, 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
redisposedeliver at once. Per-(fanout_id, principal_ref)(redisposestep 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_shapedandredisposealike. The two axes are independent — the live-record bound and the cap bound — and aredisposedeliver 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.
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; 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 C2 composition (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 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 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), 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 (FC4: 22 findings, 6 foundational; FC5: 14, 2; FC6: 13, 3; FC7: 14, 3; FC8: 10, 2; FC9: 13, 0 foundational — the first grounding gate). A post-grounding fresh-reader rescan reopened the gate: FC10 (6 findings, 1 foundational — a gate fail-closed/precedence ordering defect, OG-1); FC11 (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); FC12 (5 findings, 0 foundational — the re-grounding gate). All findings closed in-pattern every round, Pass 2 clean from FC8 on; formal-layer vote YES and discharged (TLA+ cap-TOCTOU model + overshoot buggy twin verified in tools/harness/, bound saturation recorded; the FC10–FC12 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 (C11), unblocked 2026-05-29 when Preference grounded. The constituent-set decision — compose Subscription + Notification + Preference + Event Log directly; do not compose Notification Fanout — was adjudicated in the 2026-06-11 planning session on the C9 precedent (Reservation Lifecycle 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 Preference’s own boundary declaration.
Structural milestone. Retires the forthcoming-link debts standing against C11: the C11 marker in Preference’s Composition notes and the marker on C2’s Preference-Aware Notification Fanout boundary note in Consent & Preference Management — both now resolved to links to this composition. Corrects the roadmap’s C11 row, 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 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. C11 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 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, 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:
- FC-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 C6/FC5-1 capability-provenance class). 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. - FC-11 — 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. - FC-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. - FC-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. - FC-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 C2 processing-systems-must-use-the-gate precedent), an externally-clearable disclosure, and a third named-not-discharged obligation in the TCPA standards entry.
- FC-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. - FC-1 — consumed knobs missing from Configuration — refining →
payload_digest_functionandreconciliation_windowadded as knobs. FC-2 —fanout_dispositionsremoval clause missing — refining → eviction-at-deployment-discretion clause added (every fact regenerates). FC-3 — unresolvable body references to “the formal model” — refining → rephrased to name the formal layer with a Lineage §Formal model pointer. FC-4 — phantom subscriber-accounting field in the breach scenario — refining → scenario now reconstructs the Active set atfired_atper Check 1. FC-5 — Invariant 8 had no acceptance check — refining → Check 6 added (digest-anchored content consistency). FC-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. FC-9 — journal durability overstated — refining → Invariant 10 renamed to “unique and journaled”; store durability named as the deployment’s provisioning obligation, routed externally. FC-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). FC-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. FC-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). FC-19 — Invariant 3 lacked Invariant 2’s observation anchor — refining → “as observed by the gate at disposition evaluation” propagated. FC-20 — cap example arithmetic wrong under its natural reading — refining → rewritten with explicit Monday/Tuesday timestamps. FC-21 — format default for a channels-without-format record unpinned — refining →channel_interpretationnow supplies the absent-dimension default. - FC-6 — CFR / U.S.C. unglossed — rhetorical → both spelled out at first use. FC-10 — quote attributed to Preference’s Behavior instead of Intent — rhetorical → attribution corrected. FC-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):
- FC5-6 — replay of status-dependent verdicts unsupported — foundational → the gate’s rule 1 turns on
status, the one field Preference Invariant 1 leaves mutable, soPreference.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. - FC5-7 —
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 Preference per-principal precedent); Invariant 9 cites it; the cross-store edge case enumerates it. - FC5-1 —
redisposejournal-rejectedunwired — refining → steps 2 and 4 now produce it (failed journal read; rejected disposition append, nothing committed). FC5-2 — 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). FC5-5 — query capability’s structural home unnamed — refining → the knob now names Event Log’s Reverse Index pattern (forthcoming) as the eventual constituent home. FC5-8 — replay-input type mismatch — refining → the gate’s compute-counts ∘ verdict factoring stated; replay re-enters at the verdict factor with recorded counts. FC5-9 — fail-closed missed the undeclared default shape — refining → the preamble rule now covers the no-record deliver path. FC5-10 —reconciliation_windowoptionality contradicted its consumers — refining → required for every deployment. FC5-11 — crash-gap admission unspecified — refining → step 3 now reconstructs per Check 1 and fails closed on tolerance-ambiguous membership. FC5-12 — cross-invocation accounting clock unstated — refining → Invariant 4 names the non-decreasing-to-within-clock_toleranceassumption and its deflation failure mode. FC5-13 — example gaps on the novel mechanisms — refining → fail-closed example added;not-known/payload-mismatchrejections exemplified; walkthrough configuration completed. - FC5-4 — non-verbatim C2 quotation — rhetorical → paraphrased to C2’s actual gate discipline. FC5-14 — Summary “nothing is ever silently dropped” overclaim — rhetorical → restated with the crash-detection qualifier in plain language. FC5-3 — “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):
- FC6-3 — 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). - FC6-4 — 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. - FC6-5 — 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. - FC6-2 — Subscription equality declaration overstated — refining → the byte-exact rule restated as this composition’s own declared policy, conservative against both constituents. FC6-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. FC6-7 —redisposestep 4 conflicted with “exactly as there” — refining → the journal-rejection deviation is now named as step 4’s own rule. FC6-8 — fail-closed “field” unscoped — refining → scoped to the four interpreted preference fields;metadataexcluded explicitly. FC6-9 — false analogy defendingjournal-rejected— refining → defense rewritten on its real ground (one invocation-level taxonomy; the distinction available by retry or Event Log telemetry). FC6-10 — “exactly once per invocation” contradicted byredispose— refining → Intent and Mechanism now say once per disposition. FC6-11 —redisposelacked afanout_idprimitive policy — refining → step 1 validates it. FC6-12 — ambiguous crash-gap subscriber had no terminal — refining →fanout.abandonedadded as the journaled terminal discharging the liveness arm; Check 1 evaluated modulo it. FC6-1 — “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. - FC6-13 — 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 C11 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 FC6-1 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:
- FC7-3 — 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. - FC7-5 — 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 Preference’s instance-configuration-record precedent);
config_versionjournaled per invocation; Replay semantics, Check 2, and the walkthrough evaluate under the journaled version. - FC7-6 — 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. - FC7-1 — SMS glossed late — refining → glossed at its new first use (the statutory knob). FC7-2 — gate definition referenced one-directionally — refining → step 5 now names §The load-bearing wiring decision (Mechanism). FC7-4 — constituent-store retention unpropagated — refining → the durability disclosure now covers Subscription and Preference store retention across the audit horizon. FC7-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. FC7-8 — 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). FC7-9 — abandonment terminality unenforced — refining →fanout.abandonedgained a principal scope;redisposerejects covered principals. FC7-10 —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). FC7-11 — serialized arm vs. the index’s no-consistency posture — refining →serialized-per-principalnow explicitly obligates rebuild-before-evaluation. FC7-12 — failure half of Invariant 5 unchecked — refining → Check 3 extended to the closed cause set. - FC7-13 — retry-eligibility rationale invited the wrong inference — rhetorical → terminal-for-this-invocation stated outright. FC7-14 —
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 (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 FC6-1 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 FC5–FC7 had built. All closed in-pattern:
- FC8-4 — 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. - FC8-5 —
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. - FC8-1 — Primitive policies missed
redispose’s inputs — refining →fanout_idbullet added; preamble covers both actions. FC8-2 — disclosure list incomplete against Configuration — refining →statutory_quiet_window(or its omission-as-posture) and the version-retention obligation added. FC8-3 — 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). FC8-6 — 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). FC8-7 — Check 4 contradicted the crash-gap repair — refining → the no-prior-event repair case named in the check. FC8-8 —clock_toleranceconflated two clock disciplines — refining → split into components (a) cross-store skew and (b) accounting-clock regression, with consumers re-pointed. FC8-9 — step 6 omitted the statutory-exclusion record step 5.v promised — refining → added to the deliver-pathevaluation_inputsenumeration. - FC8-10 — “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 FC6-1 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:
- FC9-2 — 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. FC9-3 — 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. FC9-4 — abandonment terminality scoped only to the crash-gap path — refining →redisposestep 3 now opens with the abandonment terminal, applied on every path. FC9-5 — 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. FC9-6 — invocation-duration bound not disclosed — refining → added to the externally-clearable disclosures. FC9-7 — 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). FC9-8 — 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). FC9-9 —journal_query_capability’s consumer list omitted the action path — refining →redispose’s runtime journal reads enumerated. - FC9-1 — Notification Invariant 2 cited but unglossed — rhetorical → gloss completed (status monotonicity is what makes the transport adjudication stable). FC9-10 — walkthrough’s single-number
clock_toleranceunqualified — rhetorical → the both-components attestation stated. FC9-11 — “in-memory” over-pinned the index’s locale — rhetorical → dropped. FC9-12 — “advisory … must treat it as binding” self-contradiction — rhetorical → unpacked (the composition advises; the deployment’s obligation makes it binding). FC9-13 — 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 FC9 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:
- OG-1 — 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). - OG-2 — 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. OG-3 — reconciliation-run obligation and abandonment-write authority unenumerated — refining → an externally-clearable “Reconciliation discipline” disclosure was added (superseded by FC11’s surface). OG-4 — “fall through to deliver” read as bypassing the statutory exclusion — rhetorical → rule 5.ii now falls through to channel selection (rule v). OG-5 — misplaced parenthetical inredisposestep 3 — rhetorical → reworded. OG-6 — “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: OG-1f — 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 (FC9-2 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 FC11 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):
- S-1 — 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). S-2 / S-4 —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. S-3 — 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. - S-5 — 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 FC10 gate-ordering defect and the FC11 reconciliation-provenance cluster closed, foundational count zero across all three passes.
Formal model — coverage note, 2026-06-12 (post-FC12). The FC10–FC12 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 (OG-1) concerns 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 OG-2 / S-2 / S-4 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.