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}