Skip to main content

frame_conv/handle/
pubsub.rs

1//! The pub/sub pattern (`frame:conv-pubsub@v1`, F-3b R1): typed
2//! publications with caller-supplied identity, fanned out by the substrate
3//! itself.
4//!
5//! The substrate IS the fan-out authority (amendment A5): topic =
6//! conversation, epoch = the substrate's linearized membership, admission
7//! witness = the commit receipt, and the epoch snapshot at admission = the
8//! membership linearization at that receipt. This module adds the typed
9//! `PublicationId` and client-side presentation bookkeeping and never
10//! re-publishes — "no fan-out worker" is true by construction.
11//!
12//! Identity discipline (amendment A4 as SUPERSEDED 2026-07-23 — the brief's
13//! amendment section carries the ruling): the `PublicationId`'s bytes ARE
14//! the record-admission attempt token (the deterministic map is the
15//! identity function), but the pinned substrate treats admission tokens as
16//! per-attempt correlation identity, NOT durable idempotency keys — a
17//! record-admission body conflict is unrepresentable on the wire at
18//! protocol 0.3.2 (`AttemptTokenBodyConflict` has no `RecordAdmission`
19//! arm; characterized live in `tests/pattern_pubsub.rs`). NO reuse refusal
20//! is therefore claimed on live bindings: republishing an id admits a NEW
21//! record, same bytes or different. Id-reuse discipline is the publisher's
22//! documented obligation; stronger admission idempotency is the named
23//! upstream ask ASK-6 (`docs/upstream/liminal-asks-2026-07-23.md`).
24//! Subscriber-side exactly-once remains the O(1) contiguity claim over
25//! admitted RECORDS — an id-keyed dedup memory of any size stays banned.
26
27use std::time::{Duration, Instant};
28
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use liminal_protocol::wire::RecordAdmissionAttemptToken;
33
34use crate::envelope::Envelope;
35use crate::error::{CallError, PublishError};
36use crate::id::{MessageKind, PatternId, PublicationId};
37use crate::outcome::{PublicationItem, PublishReceipt};
38use crate::store::ResumeStore;
39
40use super::pump::PumpStep;
41use super::state::{ConversationHandle, QueuedItem};
42
43impl<S: ResumeStore> ConversationHandle<S> {
44    /// Publishes one typed publication under the caller-supplied identity.
45    /// Schema-invalid values are refused typed BEFORE the wire. The receipt
46    /// is the admission witness (A5): the substrate owes the publication
47    /// once to every epoch live in its membership linearization at this
48    /// receipt — publisher death after it does not retract the broadcast,
49    /// and zero live epochs mean this validation plus this one admission
50    /// outcome were the entire cost.
51    ///
52    /// PUBLISHER OBLIGATION (the honest contract at the pinned substrate,
53    /// A4 superseded 2026-07-23): a `PublicationId` identifies ONE
54    /// publication — never republish an id, with the same or different
55    /// bytes. The substrate refuses neither on a live binding (admission
56    /// tokens are per-attempt identity there, not durable idempotency
57    /// keys), so a reused id admits a NEW record and every active epoch
58    /// observes it again under the same id. Stronger admission idempotency
59    /// is upstream ask ASK-6, non-gating.
60    ///
61    /// # Errors
62    ///
63    /// Returns typed publish failures.
64    pub fn publish<P: Serialize>(
65        &mut self,
66        id: PublicationId,
67        body: &P,
68    ) -> Result<PublishReceipt, PublishError> {
69        let envelope = Envelope::from_typed(
70            PatternId::PubSub,
71            id.to_correlation(),
72            MessageKind::Event,
73            body,
74        )
75        .map_err(|refusal| PublishError::SchemaInvalid {
76            detail: refusal.detail,
77        })?;
78        let bytes = envelope
79            .encode()
80            .map_err(|refusal| PublishError::Protocol {
81                detail: refusal.detail,
82            })?;
83        self.admit_with_token(bytes, RecordAdmissionAttemptToken::new(id.to_bytes()))
84    }
85
86    /// Waits up to `wait` for the next pub/sub item. `Ok(None)` is a benign
87    /// quiet wait — never an error, never a protocol outcome (the
88    /// assertion-8 pin). Items carry the guarantee documented on
89    /// [`PublicationItem`]; duplicate presentation of an admitted
90    /// publication is suppressed by the O(1) last-presented-sequence
91    /// comparison, with every suppression counted
92    /// (`duplicate_publications`), never silent.
93    ///
94    /// The effective wait granularity is the substrate's IO quantum
95    /// (hardcoded 5 s at the pinned release — upstream ASK-2): a wait
96    /// smaller than one quantum may still block for one quantum.
97    ///
98    /// # Errors
99    ///
100    /// Returns a typed connection fate.
101    pub fn next_publication<P: DeserializeOwned>(
102        &mut self,
103        wait: Duration,
104    ) -> Result<Option<PublicationItem<P>>, CallError> {
105        let started = Instant::now();
106        loop {
107            while let Some(item) = self.publications_inbox.pop_front() {
108                if let QueuedItem::Event { seq, .. } = &item {
109                    let value = seq.value();
110                    if self
111                        .last_presented_publication
112                        .is_some_and(|last| value <= last)
113                    {
114                        self.counters.duplicate_publications += 1;
115                        continue;
116                    }
117                    self.last_presented_publication = Some(value);
118                }
119                return Ok(Some(convert_publication(item)));
120            }
121            if started.elapsed() >= wait {
122                return Ok(None);
123            }
124            match self.pump_step() {
125                Ok(PumpStep::Classified | PumpStep::Quiet) => {}
126                Err(detail) => return Err(CallError::ConnectionLost { detail }),
127            }
128        }
129    }
130}
131
132/// Converts a queued item into the typed pub/sub surface, decoding
133/// publication payloads against the caller's typed message
134/// (schema-invalid surfaces typed, never dropped) and recovering the
135/// publication identity from the record's correlation slot.
136fn convert_publication<P: DeserializeOwned>(item: QueuedItem) -> PublicationItem<P> {
137    match item {
138        QueuedItem::Event {
139            envelope,
140            publisher,
141            seq,
142        } => {
143            let id = PublicationId::from_correlation(envelope.correlation);
144            match envelope.decode_payload::<P>() {
145                Ok(body) => PublicationItem::Publication {
146                    id,
147                    body,
148                    publisher,
149                    seq,
150                },
151                Err(refusal) => PublicationItem::SchemaInvalid {
152                    id,
153                    publisher,
154                    seq,
155                    detail: refusal.detail,
156                },
157            }
158        }
159        QueuedItem::Joined { peer, seq } => PublicationItem::PeerJoined { peer, seq },
160        QueuedItem::Departed { peer, seq, reason } => {
161            PublicationItem::PeerDeparted { peer, seq, reason }
162        }
163        QueuedItem::Failed { peer, seq, failure } => {
164            PublicationItem::PeerFailed { peer, seq, failure }
165        }
166        QueuedItem::Compacted { seq } => PublicationItem::HistoryCompacted { seq },
167        QueuedItem::Gap { expected, observed } => PublicationItem::Gap { expected, observed },
168    }
169}