Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:17:21.061Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

sarah_conversation.rs

7289 lines · 308.8 KB · rust
1//! Sarah Nostr conversation client for `openagents.omega.effectd.v1`.
2//!
3//! Packet: SARAH-NR-06 (OpenAgentsInc/omega#33).
4//! Spec: docs/omega/2026-07-24-sarah-workroom-mvp-spec.md §8, §24.7.
5//!
6//! This module is the only conversation client for the Sarah lane. It must
7//! never link a Khala Sync client. The backing transport is a Nostr relay
8//! adapter: mock/in-memory for local tests, NIP-42 authenticated when a real
9//! relay URL and identity key are configured.
10
11#[cfg(test)]
12use std::cell::Cell;
13use std::collections::{BTreeMap, VecDeque};
14use std::fs::{self, OpenOptions};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use nostr::{
21    Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, Tag,
22    nips::{
23        nip42,
24        nip44::{self, Version as Nip44Version},
25    },
26};
27use omega_identity::{
28    AdmittedSigningRequest, IdentityService, NostrPublicKeyHex, PrivateMessageRequest, ReceiptRef,
29    SigningPurpose, UnsignedEventTemplate,
30};
31use serde::{Deserialize, Serialize};
32use serde_json::{Value, json};
33use sha2::{Digest, Sha256};
34use thiserror::Error;
35
36use crate::protocol::{MAX_FRAME_BYTES, PROTOCOL_SCHEMA};
37use crate::{
38    ISSUE31_ADJUNCT_DELIVERY_KEYS, ISSUE31_COMMAND_SCHEMA, ISSUE31_COMMAND_SCHEMA_V2,
39    ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE, ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
40    ISSUE31_HOST_ADJUNCT_RECORD_TYPE, ISSUE31_HOST_ADJUNCT_SCHEMA, ISSUE31_HOST_DISCOVERY_KIND,
41    ISSUE31_PAIRING_SCHEMA, Issue31AuthorityDecisionProjection, Issue31CommandArguments,
42    Issue31CommandEvent, Issue31CommandExecution, Issue31CommandExecutionV2,
43    Issue31CommandHandlingStatus, Issue31CommandRecord, Issue31CommandRecordV2,
44    Issue31CommandStatus, Issue31HostConfiguration, Issue31HostController, Issue31HostDiscovery,
45    Issue31HostDiscoveryV2, Issue31NostrError, Issue31OwnerProjectionBody,
46    Issue31GrantState, Issue31OwnerProjectionInput, Issue31PairingEvent, Issue31PairingRecord,
47    Issue31SourceRole, Issue31TargetOutcomeProjection, Issue31WithheldCause,
48    Issue31WithheldSourceCount, Issue31WithheldSourcesInput, SARAH_AUTHORITY_RECEIPT_KIND,
49    SARAH_ENGRAM_KIND, SARAH_READ_STATE_KIND, SARAH_REMINDER_KIND, emit_issue31_owner_projection,
50    emit_issue31_withheld_sources,
51};
52use crate::issue31_provider_handoff::{
53    ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF, Issue31ProviderHandoffLedger,
54    Issue31ProviderRosterAccount,
55};
56
57pub use crate::openagents_binding::BindingState;
58
59/// Framed method names on `openagents.omega.effectd.v1` for the Sarah room.
60pub const SARAH_METHOD_SESSION_STATUS: &str = "sarah_session_status";
61pub const SARAH_METHOD_BOOTSTRAP: &str = "sarah_bootstrap";
62pub const SARAH_METHOD_ROOM_SNAPSHOT: &str = "sarah_room_snapshot";
63pub const SARAH_METHOD_SEND_MESSAGE: &str = "sarah_send_message";
64pub const SARAH_METHOD_INTERRUPT_TURN: &str = "sarah_interrupt_turn";
65pub const SARAH_METHOD_DEVICE_GRANTS: &str = "sarah_device_grants";
66pub const SARAH_METHOD_RENEW_DEVICE_GRANT: &str = "sarah_renew_device_grant";
67pub const SARAH_METHOD_REVOKE_DEVICE_GRANT: &str = "sarah_revoke_device_grant";
68/// Owner re-admission of a device whose grant was revoked. Revocation fails
69/// closed for the device, not just the grant, so without this the owner has no
70/// way to let a device back in.
71pub const SARAH_METHOD_READMIT_DEVICE: &str = "sarah_readmit_device";
72pub const SARAH_EVENT_ROOM_EVENT: &str = "sarah_room_event";
73pub const SARAH_EVENT_ROOM_STATE: &str = "sarah_room_state";
74
75pub const SARAH_FRAMED_METHODS: &[&str] = &[
76    SARAH_METHOD_SESSION_STATUS,
77    SARAH_METHOD_BOOTSTRAP,
78    SARAH_METHOD_ROOM_SNAPSHOT,
79    SARAH_METHOD_SEND_MESSAGE,
80    SARAH_METHOD_INTERRUPT_TURN,
81    SARAH_METHOD_DEVICE_GRANTS,
82    SARAH_METHOD_RENEW_DEVICE_GRANT,
83    SARAH_METHOD_REVOKE_DEVICE_GRANT,
84    SARAH_METHOD_READMIT_DEVICE,
85];
86
87/// NIP-AO ephemeral control kind used for interrupt / cancel_turn.
88pub const NIP_AO_KIND: u16 = 24200;
89/// Durable Sarah turn-record kind (SARAH-NR-00).
90pub const SARAH_TURN_RECORD_KIND: u16 = 44300;
91
92const DEFAULT_PAGE_LIMIT: usize = 32;
93const MAX_PAGE_LIMIT: usize = 64;
94const MAX_PENDING_EVENTS: usize = 256;
95const MAX_COMMAND_RESULTS: usize = 4_096;
96const MAX_PRIVATE_OUTBOX_ITEMS: usize = 1_024;
97const MAX_RELAY_ACKNOWLEDGEMENTS: usize = 4_096;
98const MAX_QUARANTINED_ISSUE31_EVENTS: usize = 4_096;
99/// The quarantine reason recorded when a source event cannot become a
100/// projection. It is the only quarantine reason that withholds something the
101/// owner was entitled to read; the pairing and command reasons quarantine
102/// control records, which are not part of the owner's view.
103const ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON: &str = "reason.omega.invalid_projection_source";
104const ISSUE31_PROJECTION_SCAN_BOUND_REASON: &str = "reason.omega.projection_scan_bound";
105const CURSOR_PREFIX: &str = "cursor.";
106const MOCK_RELAY_LABEL: &str = "mock://local";
107
108#[derive(Debug, Error)]
109pub enum SarahConversationError {
110    #[error("stale generation: expected {expected}, got {got}")]
111    StaleGeneration { expected: u64, got: u64 },
112    #[error("invalid request: {0}")]
113    InvalidRequest(String),
114    #[error("identity required for authenticated relay")]
115    IdentityRequired,
116    #[error("identity custody error: {0}")]
117    Identity(String),
118    #[error("relay error: {0}")]
119    Relay(String),
120    #[error("internal: {0}")]
121    Internal(String),
122}
123
124impl SarahConversationError {
125    pub fn protocol_code(&self) -> crate::protocol::ProtocolErrorCode {
126        use crate::protocol::ProtocolErrorCode;
127        match self {
128            Self::StaleGeneration { .. } => ProtocolErrorCode::StaleGeneration,
129            Self::InvalidRequest(_) | Self::IdentityRequired => ProtocolErrorCode::InvalidRequest,
130            Self::Identity(_) => ProtocolErrorCode::HostUnavailable,
131            Self::Relay(_) => ProtocolErrorCode::HostUnavailable,
132            Self::Internal(_) => ProtocolErrorCode::Internal,
133        }
134    }
135}
136
137/// Public-safe identity projection. Never carries a private key or token.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "camelCase")]
140pub struct ConversationIdentity {
141    pub owner_public_key_hex: String,
142    pub sarah_public_key_hex: String,
143    pub account_label: Option<String>,
144    /// Binding state for metering attribution (OMEGA-SW-01). Not a session token.
145    pub binding_state: BindingState,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "camelCase")]
150pub struct SessionStatusResult {
151    pub signed_in: bool,
152    pub account_label: Option<String>,
153    pub binding_state: BindingState,
154    pub owner_public_key_hex: Option<String>,
155    /// ISO-8601 expiry for the OpenAgents account binding, when known.
156    pub binding_expires_at: Option<String>,
157    pub transport: String,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(rename_all = "camelCase")]
162pub struct BootstrapResult {
163    pub principal_ref: String,
164    pub display_name: String,
165    pub role: String,
166    pub conversation_ref: String,
167    pub legacy_thread_ref: String,
168    pub owner_public_key_hex: String,
169    pub sarah_public_key_hex: String,
170    pub authority_profile_ref: String,
171    pub authority_profile_revision: u32,
172    pub admitted_device_fingerprints: Vec<String>,
173    pub quarantined_issue31_event_count: usize,
174    pub room_state: RoomStateEvent,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "camelCase")]
179pub struct RoomSnapshotResult {
180    pub conversation_ref: String,
181    pub transcript: TranscriptPage,
182    pub activity: ActivityPage,
183    pub nostr_records: NostrRecordPage,
184    pub run_state: RunStateProjection,
185    pub room_state: RoomStateEvent,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "camelCase")]
190pub struct NostrRecordPage {
191    pub entries: Vec<NostrRecordRef>,
192    pub cursor: String,
193    pub next_cursor: Option<String>,
194    pub gap_state: GapState,
195    pub source: String,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase")]
200pub struct NostrRecordRef {
201    pub event_id: String,
202    pub cursor: String,
203    pub kind: u16,
204    pub record_kind: String,
205    pub author_fingerprint: String,
206    pub created_at: String,
207    pub source: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212pub struct TranscriptPage {
213    pub entries: Vec<TranscriptEntry>,
214    pub cursor: String,
215    pub next_cursor: Option<String>,
216    pub gap_state: GapState,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase")]
221pub struct TranscriptEntry {
222    pub event_id: String,
223    pub cursor: String,
224    pub role: String,
225    pub kind: String,
226    pub text: String,
227    pub created_at: String,
228    pub status: String,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "camelCase")]
233pub struct ActivityPage {
234    pub entries: Vec<ActivityEntry>,
235    pub cursor: String,
236    pub next_cursor: Option<String>,
237    pub gap_state: GapState,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241#[serde(rename_all = "camelCase")]
242pub struct ActivityEntry {
243    pub event_id: String,
244    pub cursor: String,
245    pub entry: String,
246    pub turn_ref: String,
247    pub created_at: String,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251#[serde(rename_all = "camelCase")]
252pub struct RunStateProjection {
253    pub state: String,
254    pub turn_ref: Option<String>,
255    pub reason: Option<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(rename_all = "camelCase")]
260pub struct SendMessageResult {
261    pub accepted: bool,
262    pub message_ref: String,
263    pub turn_ref: String,
264    pub event_id: String,
265    pub cursor: String,
266    pub status: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "camelCase")]
271pub struct InterruptTurnResult {
272    pub accepted: bool,
273    pub turn_ref: String,
274    pub intent_ref: String,
275    pub status: String,
276    pub pending: bool,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
280#[serde(rename_all = "camelCase")]
281pub struct RoomEventPayload {
282    pub method: String,
283    pub conversation_ref: String,
284    pub cursor: String,
285    pub record: TranscriptEntry,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(rename_all = "camelCase")]
290pub struct RoomStateEvent {
291    pub method: String,
292    pub connection: ConnectionState,
293    pub freshness: FreshnessState,
294    pub gap_state: GapState,
295    pub connected_relays: Vec<String>,
296    pub last_acknowledged_event_id: Option<String>,
297    pub last_acknowledged_cursor: Option<String>,
298    pub authenticated: bool,
299    pub transport: String,
300}
301
302#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
303#[serde(rename_all = "snake_case")]
304pub enum ConnectionState {
305    Disconnected,
306    Connecting,
307    Connected,
308    Degraded,
309}
310
311#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
312#[serde(rename_all = "snake_case")]
313pub enum FreshnessState {
314    Fresh,
315    Stale,
316    Unknown,
317}
318
319#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
320#[serde(rename_all = "snake_case")]
321pub enum GapState {
322    None,
323    Possible,
324    Confirmed,
325    Recovering,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
329#[serde(rename_all = "camelCase")]
330pub struct RelayAuthChallenge {
331    pub challenge: String,
332    pub relay_url: String,
333}
334
335/// Stored public-safe event in the conversation store.
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "camelCase")]
338pub struct StoredConversationEvent {
339    pub event_id: String,
340    pub kind: u16,
341    pub pubkey: String,
342    pub created_at: u64,
343    pub conversation_ref: String,
344    pub content_summary: String,
345    pub tags: Vec<Vec<String>>,
346    /// Whether this is an owner message, Sarah answer, activity entry, or control.
347    pub record_kind: String,
348    pub store_index: usize,
349}
350
351#[derive(Debug, Clone)]
352pub struct QueryPage {
353    pub events: Vec<StoredConversationEvent>,
354    pub next_cursor: Option<String>,
355    pub gap_state: GapState,
356}
357
358/// Abstract Nostr relay transport. Production will use a real WebSocket client;
359/// tests and local-dev without a relay use [`MockRelayAdapter`].
360pub trait RelayTransport: Send {
361    fn label(&self) -> &str;
362    fn connection_state(&self) -> ConnectionState;
363    fn is_authenticated(&self) -> bool;
364    fn connect(&mut self) -> Result<(), SarahConversationError>;
365    fn auth_challenge(&self) -> Option<RelayAuthChallenge>;
366    fn authenticate(&mut self, auth_event: &Event) -> Result<(), SarahConversationError>;
367    fn publish(&mut self, event: &Event) -> Result<(), SarahConversationError>;
368    fn publication_complete(&mut self, _event_id: &str) -> bool {
369        true
370    }
371    fn acknowledged_relays(&self, _event_id: &str) -> Vec<String> {
372        vec![self.label().to_string()]
373    }
374    fn restore_publication_acknowledgements(&mut self, _event_id: &str, _relay_urls: &[String]) {}
375    fn query(
376        &mut self,
377        conversation_ref: &str,
378        after_cursor: Option<&str>,
379        limit: usize,
380    ) -> Result<QueryPage, SarahConversationError>;
381    fn last_event_id(&self) -> Option<String>;
382    fn gap_state(&self) -> GapState {
383        GapState::None
384    }
385    fn connected_relays(&self) -> Vec<String> {
386        vec![self.label().to_string()]
387    }
388    fn requires_private_messages(&self) -> bool {
389        false
390    }
391}
392
393/// In-memory mock relay for local/dev and unit tests.
394#[derive(Debug, Default)]
395pub struct MockRelayAdapter {
396    label: String,
397    connected: bool,
398    authenticated: bool,
399    require_auth: bool,
400    challenge: Option<String>,
401    events: Vec<StoredConversationEvent>,
402}
403
404impl MockRelayAdapter {
405    pub fn new() -> Self {
406        Self {
407            label: MOCK_RELAY_LABEL.to_string(),
408            connected: false,
409            authenticated: false,
410            require_auth: false,
411            challenge: None,
412            events: Vec::new(),
413        }
414    }
415
416    /// Local mock that still exercises the NIP-42 AUTH path.
417    pub fn with_required_auth(challenge: impl Into<String>) -> Self {
418        Self {
419            label: "mock://auth-required".to_string(),
420            require_auth: true,
421            challenge: Some(challenge.into()),
422            ..Self::new()
423        }
424    }
425
426    pub fn seed_event(&mut self, mut event: StoredConversationEvent) {
427        event.store_index = self.events.len();
428        self.events.push(event);
429    }
430
431    pub fn event_count(&self) -> usize {
432        self.events.len()
433    }
434}
435
436impl RelayTransport for MockRelayAdapter {
437    fn label(&self) -> &str {
438        &self.label
439    }
440
441    fn connection_state(&self) -> ConnectionState {
442        if self.connected {
443            ConnectionState::Connected
444        } else {
445            ConnectionState::Disconnected
446        }
447    }
448
449    fn is_authenticated(&self) -> bool {
450        self.authenticated || !self.require_auth
451    }
452
453    fn connect(&mut self) -> Result<(), SarahConversationError> {
454        self.connected = true;
455        if self.require_auth {
456            self.authenticated = false;
457            if self.challenge.is_none() {
458                self.challenge = Some(format!("challenge-{}", self.events.len()));
459            }
460        } else {
461            self.authenticated = true;
462        }
463        Ok(())
464    }
465
466    fn auth_challenge(&self) -> Option<RelayAuthChallenge> {
467        if self.require_auth && !self.authenticated {
468            Some(RelayAuthChallenge {
469                challenge: self.challenge.clone().unwrap_or_default(),
470                relay_url: "wss://relay.openagents.com".to_string(),
471            })
472        } else {
473            None
474        }
475    }
476
477    fn authenticate(&mut self, auth_event: &Event) -> Result<(), SarahConversationError> {
478        let challenge = self
479            .challenge
480            .as_deref()
481            .ok_or_else(|| SarahConversationError::Relay("no auth challenge pending".into()))?;
482        let relay_url = RelayUrl::parse("wss://relay.openagents.com")
483            .map_err(|error| SarahConversationError::Relay(error.to_string()))?;
484        if !nip42::is_valid_auth_event(auth_event, &relay_url, challenge) {
485            return Err(SarahConversationError::Relay(
486                "NIP-42 auth event rejected".into(),
487            ));
488        }
489        if auth_event.verify().is_err() {
490            return Err(SarahConversationError::Relay(
491                "NIP-42 auth signature invalid".into(),
492            ));
493        }
494        self.authenticated = true;
495        Ok(())
496    }
497
498    fn publish(&mut self, event: &Event) -> Result<(), SarahConversationError> {
499        if !self.connected {
500            return Err(SarahConversationError::Relay("not connected".into()));
501        }
502        if self.require_auth && !self.authenticated {
503            return Err(SarahConversationError::Relay(
504                "authentication required before publish".into(),
505            ));
506        }
507        let conversation_ref = event
508            .tags
509            .iter()
510            .find_map(|tag| {
511                let slice = tag.as_slice();
512                if slice.first().map(String::as_str) == Some("conversation") {
513                    slice.get(1).cloned()
514                } else {
515                    None
516                }
517            })
518            .unwrap_or_else(|| "sarah.unknown".to_string());
519        let record_kind = match event.kind.as_u16() {
520            NIP_AO_KIND => "control",
521            SARAH_TURN_RECORD_KIND => "activity",
522            SARAH_READ_STATE_KIND => "read_state",
523            SARAH_REMINDER_KIND => "reminder",
524            _ => "message",
525        };
526        let store_index = self.events.len();
527        self.events.push(StoredConversationEvent {
528            event_id: event.id.to_hex(),
529            kind: event.kind.as_u16(),
530            pubkey: event.pubkey.to_hex(),
531            created_at: event.created_at.as_secs(),
532            conversation_ref,
533            content_summary: redact_content_summary(&event.content),
534            tags: event
535                .tags
536                .iter()
537                .map(|tag| tag.as_slice().to_vec())
538                .collect(),
539            record_kind: record_kind.to_string(),
540            store_index,
541        });
542        Ok(())
543    }
544
545    fn query(
546        &mut self,
547        conversation_ref: &str,
548        after_cursor: Option<&str>,
549        limit: usize,
550    ) -> Result<QueryPage, SarahConversationError> {
551        if !self.connected {
552            return Err(SarahConversationError::Relay("not connected".into()));
553        }
554        let matching: Vec<StoredConversationEvent> = self
555            .events
556            .iter()
557            .filter(|event| {
558                event.conversation_ref == conversation_ref
559                    || matches!(event.kind, SARAH_READ_STATE_KIND | SARAH_REMINDER_KIND)
560            })
561            .cloned()
562            .collect();
563        let start_index = after_cursor
564            .and_then(|cursor| {
565                matching
566                    .iter()
567                    .position(|event| stored_event_cursor(event) == cursor)
568            })
569            .map(|index| index.saturating_add(1))
570            .unwrap_or(0);
571        let page: Vec<StoredConversationEvent> = matching
572            .iter()
573            .skip(start_index)
574            .take(limit)
575            .cloned()
576            .collect();
577        let next_cursor = if start_index.saturating_add(page.len()) < matching.len() {
578            page.last().map(stored_event_cursor)
579        } else {
580            None
581        };
582        Ok(QueryPage {
583            events: page,
584            next_cursor,
585            gap_state: GapState::None,
586        })
587    }
588
589    fn last_event_id(&self) -> Option<String> {
590        self.events.last().map(|event| event.event_id.clone())
591    }
592}
593
594/// Optional identity material used only for signing. Secrets never leave this
595/// struct into framed protocol responses.
596pub struct SigningIdentity {
597    pub public_key_hex: String,
598    keys: Keys,
599}
600
601impl SigningIdentity {
602    pub fn from_keys(keys: Keys) -> Self {
603        Self {
604            public_key_hex: keys.public_key().to_hex(),
605            keys,
606        }
607    }
608
609    pub fn generate() -> Self {
610        Self::from_keys(Keys::generate())
611    }
612
613    pub fn sign_auth(
614        &self,
615        challenge: &str,
616        relay_url: &str,
617    ) -> Result<Event, SarahConversationError> {
618        let relay = RelayUrl::parse(relay_url)
619            .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
620        EventBuilder::auth(challenge, relay)
621            .sign_with_keys(&self.keys)
622            .map_err(|error| SarahConversationError::Internal(error.to_string()))
623    }
624
625    pub fn sign_text_note(
626        &self,
627        content: &str,
628        tags: Vec<Tag>,
629    ) -> Result<Event, SarahConversationError> {
630        EventBuilder::text_note(content)
631            .tags(tags)
632            .sign_with_keys(&self.keys)
633            .map_err(|error| SarahConversationError::Internal(error.to_string()))
634    }
635
636    pub fn sign_custom(
637        &self,
638        kind: u16,
639        content: &str,
640        tags: Vec<Tag>,
641    ) -> Result<Event, SarahConversationError> {
642        EventBuilder::new(Kind::from(kind), content)
643            .tags(tags)
644            .sign_with_keys(&self.keys)
645            .map_err(|error| SarahConversationError::Internal(error.to_string()))
646    }
647}
648
649enum ConversationSigner {
650    Keys(SigningIdentity),
651    OmegaIdentity(Arc<IdentityService>),
652}
653
654impl ConversationSigner {
655    fn sign_public_record(
656        &self,
657        kind: u16,
658        content: &str,
659        tags: Vec<Tag>,
660    ) -> Result<Event, SarahConversationError> {
661        match self {
662            Self::Keys(identity) => identity.sign_custom(kind, content, tags),
663            Self::OmegaIdentity(identity_service) => {
664                let custody = identity_service
665                    .inspect()
666                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
667                let identity = custody
668                    .identity
669                    .ok_or(SarahConversationError::IdentityRequired)?;
670                let semantic_binding = serde_json::to_vec(&json!({
671                    "kind": kind,
672                    "content": content,
673                    "tags": tags.iter().map(|tag| tag.as_slice()).collect::<Vec<_>>(),
674                }))
675                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
676                let request = AdmittedSigningRequest {
677                    request_ref: digest_receipt_ref("issue31.public", &semantic_binding)?,
678                    identity_ref: identity.identity_ref().clone(),
679                    purpose: SigningPurpose::NostrEvent,
680                    event: UnsignedEventTemplate {
681                        created_at: unix_now(),
682                        kind,
683                        tags: tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
684                        content: content.to_string(),
685                    },
686                };
687                let signed = identity_service
688                    .sign(&request)
689                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
690                Event::from_json(signed.signed_event_json)
691                    .map_err(|error| SarahConversationError::Identity(error.to_string()))
692            }
693        }
694    }
695
696    fn sign_encrypted_self_record(
697        &self,
698        kind: u16,
699        plaintext: &str,
700        tags: Vec<Tag>,
701    ) -> Result<Event, SarahConversationError> {
702        match self {
703            Self::Keys(identity) => {
704                let ciphertext = nip44::encrypt(
705                    identity.keys.secret_key(),
706                    &identity.keys.public_key(),
707                    plaintext.as_bytes(),
708                    Nip44Version::V2,
709                )
710                .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
711                identity.sign_custom(kind, &ciphertext, tags)
712            }
713            Self::OmegaIdentity(identity_service) => {
714                let custody = identity_service
715                    .inspect()
716                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
717                let identity = custody
718                    .identity
719                    .ok_or(SarahConversationError::IdentityRequired)?;
720                let semantic_binding = serde_json::to_vec(&json!({
721                    "kind": kind,
722                    "plaintextDigest": format!("{:x}", Sha256::digest(plaintext.as_bytes())),
723                    "tags": tags.iter().map(|tag| tag.as_slice()).collect::<Vec<_>>(),
724                }))
725                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
726                let request = AdmittedSigningRequest {
727                    request_ref: digest_receipt_ref("issue31.encrypted-self", &semantic_binding)?,
728                    identity_ref: identity.identity_ref().clone(),
729                    purpose: SigningPurpose::Nip44EncryptedSelfEvent,
730                    event: UnsignedEventTemplate {
731                        created_at: unix_now(),
732                        kind,
733                        tags: tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
734                        content: plaintext.to_string(),
735                    },
736                };
737                let signed = identity_service
738                    .sign_nip44_encrypted_to_self(&request)
739                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
740                Event::from_json(signed.signed_event_json)
741                    .map_err(|error| SarahConversationError::Identity(error.to_string()))
742            }
743        }
744    }
745
746    fn decrypt_record(
747        &self,
748        sender_public_key_hex: &str,
749        ciphertext: &str,
750    ) -> Result<String, SarahConversationError> {
751        match self {
752            Self::Keys(identity) => {
753                let sender_public_key = PublicKey::from_hex(sender_public_key_hex)
754                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
755                nip44::decrypt(
756                    identity.keys.secret_key(),
757                    &sender_public_key,
758                    ciphertext.as_bytes(),
759                )
760                .map_err(|error| SarahConversationError::Identity(error.to_string()))
761            }
762            Self::OmegaIdentity(identity_service) => {
763                let sender_public_key = NostrPublicKeyHex::new(sender_public_key_hex)
764                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
765                identity_service
766                    .decrypt_nip44_from(&sender_public_key, ciphertext)
767                    .map_err(|error| SarahConversationError::Identity(error.to_string()))
768            }
769        }
770    }
771
772    fn sign_auth(&self, challenge: &str, relay_url: &str) -> Result<Event, SarahConversationError> {
773        match self {
774            Self::Keys(identity) => identity.sign_auth(challenge, relay_url),
775            Self::OmegaIdentity(identity_service) => {
776                let custody = identity_service
777                    .inspect()
778                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
779                let identity = custody
780                    .identity
781                    .ok_or(SarahConversationError::IdentityRequired)?;
782                let public_key = PublicKey::from_hex(identity.public_key_hex().as_str())
783                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
784                let relay = RelayUrl::parse(relay_url)
785                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
786                let unsigned = EventBuilder::auth(challenge, relay).build(public_key);
787                let request_ref = digest_receipt_ref("nip42", challenge.as_bytes())?;
788                let request = AdmittedSigningRequest {
789                    request_ref,
790                    identity_ref: identity.identity_ref().clone(),
791                    purpose: SigningPurpose::NostrEvent,
792                    event: UnsignedEventTemplate {
793                        created_at: unsigned.created_at.as_secs(),
794                        kind: unsigned.kind.as_u16(),
795                        tags: unsigned
796                            .tags
797                            .iter()
798                            .map(|tag| tag.as_slice().to_vec())
799                            .collect(),
800                        content: unsigned.content,
801                    },
802                };
803                let signed = identity_service
804                    .sign(&request)
805                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
806                Event::from_json(signed.signed_event_json)
807                    .map_err(|error| SarahConversationError::Identity(error.to_string()))
808            }
809        }
810    }
811
812    fn private_messages(
813        &self,
814        content: &str,
815        tags: Vec<Tag>,
816        recipients: &[String],
817    ) -> Result<(String, Vec<Event>), SarahConversationError> {
818        let created_at = unix_now();
819        let semantic_binding = serde_json::to_vec(&json!({
820            "content": content,
821            "tags": tags.iter().map(|tag| tag.as_slice()).collect::<Vec<_>>(),
822            "recipients": recipients,
823        }))
824        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
825        match self {
826            Self::Keys(identity) => {
827                let public_key = identity.keys.public_key();
828                let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
829                    .tags(tags)
830                    .custom_created_at(nostr::Timestamp::from_secs(created_at))
831                    .build(public_key);
832                rumor.ensure_id();
833                rumor
834                    .verify_id()
835                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
836                let rumor_event_id = rumor
837                    .id
838                    .ok_or_else(|| {
839                        SarahConversationError::Identity(
840                            "private rumor omitted its convergence id".into(),
841                        )
842                    })?
843                    .to_hex();
844                let mut events = Vec::with_capacity(recipients.len());
845                for recipient in recipients {
846                    let recipient = PublicKey::from_hex(recipient).map_err(|error| {
847                        SarahConversationError::InvalidRequest(error.to_string())
848                    })?;
849                    let gift_wrap = smol::block_on(EventBuilder::gift_wrap(
850                        &identity.keys,
851                        &recipient,
852                        rumor.clone(),
853                        [],
854                    ))
855                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
856                    events.push(gift_wrap);
857                }
858                Ok((rumor_event_id, events))
859            }
860            Self::OmegaIdentity(identity_service) => {
861                let custody = identity_service
862                    .inspect()
863                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
864                let identity = custody
865                    .identity
866                    .ok_or(SarahConversationError::IdentityRequired)?;
867                let recipients = recipients
868                    .iter()
869                    .map(NostrPublicKeyHex::new)
870                    .collect::<Result<Vec<_>, _>>()
871                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
872                let request = PrivateMessageRequest {
873                    request_ref: digest_receipt_ref("nip17", &semantic_binding)?,
874                    identity_ref: identity.identity_ref().clone(),
875                    recipients,
876                    rumor: UnsignedEventTemplate {
877                        created_at,
878                        kind: Kind::PrivateDirectMessage.as_u16(),
879                        tags: tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
880                        content: content.to_string(),
881                    },
882                };
883                let wrapped = identity_service
884                    .gift_wrap_private_message(&request)
885                    .map_err(|error| SarahConversationError::Identity(error.to_string()))?;
886                let rumor_event_id = wrapped
887                    .first()
888                    .map(|wrapped| wrapped.rumor_event_id.clone())
889                    .ok_or_else(|| {
890                        SarahConversationError::Identity(
891                            "private message produced no gift wraps".into(),
892                        )
893                    })?;
894                let events = wrapped
895                    .into_iter()
896                    .map(|wrapped| {
897                        Event::from_json(wrapped.gift_wrap_event_json)
898                            .map_err(|error| SarahConversationError::Identity(error.to_string()))
899                    })
900                    .collect::<Result<Vec<_>, _>>()?;
901                Ok((rumor_event_id, events))
902            }
903        }
904    }
905}
906
907/// Configuration for the conversation client.
908#[derive(Debug, Clone)]
909pub struct SarahConversationConfig {
910    pub generation: u64,
911    pub conversation_digest: String,
912    pub identity: ConversationIdentity,
913    /// When set, the client treats the transport as a real relay and runs NIP-42.
914    pub relay_url: Option<String>,
915    pub admitted_device_public_key_hexes: Vec<String>,
916    pub approved_device_scopes: Vec<crate::Issue31PairingScope>,
917    pub community_group_ids: Vec<String>,
918    pub community_public_key_hexes: Vec<String>,
919}
920
921impl SarahConversationConfig {
922    pub fn mock_fixture() -> Self {
923        Self {
924            generation: 1,
925            conversation_digest: "a".repeat(24),
926            identity: ConversationIdentity {
927                owner_public_key_hex: "b".repeat(64),
928                sarah_public_key_hex: "c".repeat(64),
929                account_label: Some("owner@example.com".to_string()),
930                binding_state: BindingState::Bound,
931            },
932            relay_url: None,
933            admitted_device_public_key_hexes: Vec::new(),
934            approved_device_scopes: Vec::new(),
935            community_group_ids: Vec::new(),
936            community_public_key_hexes: Vec::new(),
937        }
938    }
939
940    pub fn conversation_ref(&self) -> String {
941        format!("sarah.{}", self.conversation_digest)
942    }
943
944    pub fn legacy_thread_ref(&self) -> String {
945        format!("thread.sarah.{}", self.conversation_digest)
946    }
947}
948
949/// Nostr conversation client owned by omega-effectd for the Sarah lane.
950/// One admitted device's standing, handed to the Full Auto reader (omega#49).
951///
952/// The host pump owns this shape rather than the panel: the panel knows what
953/// the runs are, and only the pump knows which devices are entitled to see
954/// them and under which grant. Building the snapshot per grant is what makes
955/// `connection_identity` a statement about *this* reader instead of a generic
956/// one.
957#[derive(Clone, Copy, Debug)]
958pub struct Issue31HostProjectionRequest<'a> {
959    pub host_ref: &'a str,
960    pub host_public_key_hex: &'a str,
961    pub device_public_key_hex: &'a str,
962    pub grant_ref: &'a str,
963    pub expected_generation: u64,
964    /// The pump's reading time, in epoch milliseconds.
965    pub observed_at_ms: u64,
966    /// The host's own provider connection handoff ledger (omega#91).
967    ///
968    /// This comes from the pump rather than from the Full Auto reading for the
969    /// same reason the delivery binding does: the ledger is durable host state
970    /// that survives a restart, and the panel's reading is a transient
971    /// observation of the daemon. One of them is the record; letting the other
972    /// also carry handoffs would give the phone two sources for one fact.
973    ///
974    /// The ledger is handed over unprojected on purpose. Only the builder knows
975    /// the `generatedAtMs` the documents will carry, and a handoff row must be
976    /// checked against exactly that stamp — projecting here, against the pump's
977    /// clock, would accept rows the assembled document then contradicts.
978    pub handoffs: &'a Issue31ProviderHandoffLedger,
979}
980
981/// The two omega#47 documents `publish_issue31_host_snapshot` produces.
982///
983/// They travel as JSON because the builder lives in `full_auto_ui`, which
984/// depends on this crate. Inverting that to call the builder from here would
985/// make the dependency circular, so the reading crosses the seam as data.
986#[derive(Clone, Debug)]
987pub struct Issue31HostProjectionDocuments {
988    pub host: Value,
989    pub detail: Value,
990}
991
992/// A live reading of Full Auto host state, or `None`.
993///
994/// `None` means this host cannot presently state its Full Auto view — no
995/// supervisor attached, no panel reading, nothing observed. It is not an empty
996/// view: an empty view is a `Some` carrying zero runs, which the device renders
997/// as "this host is running nothing". Publishing an invented empty projection
998/// for an unobserved host would be exactly the false claim omega#49 forbids.
999pub type Issue31HostProjectionSource = Arc<
1000    dyn Fn(
1001            &Issue31HostProjectionRequest<'_>,
1002        ) -> Result<Option<Issue31HostProjectionDocuments>, String>
1003        + Send
1004        + Sync,
1005>;
1006
1007/// The host's own reading of its provider accounts, or `None` (omega#91).
1008///
1009/// `None` means the host has not looked at its roster. Nothing about a handoff
1010/// advances on an unread roster: a binding decided against state nobody read
1011/// would be a guess wearing a measurement's clothes. `Some(&[])` — the host
1012/// looked and holds no accounts — is a real observation and does move a
1013/// handoff's clock towards its deadline.
1014pub type Issue31ProviderRosterSource =
1015    Arc<dyn Fn() -> Option<Vec<Issue31ProviderRosterAccount>> + Send + Sync>;
1016
1017pub struct SarahConversationClient {
1018    config: SarahConversationConfig,
1019    relay: Box<dyn RelayTransport>,
1020    signer: ConversationSigner,
1021    pending_events: VecDeque<Value>,
1022    active_turn_ref: Option<String>,
1023    run_state: String,
1024    message_seq: u64,
1025    command_results: BTreeMap<String, (String, Value)>,
1026    last_gap_state: GapState,
1027    last_confirmed_cursor: Option<String>,
1028    issue31_host: Option<Issue31HostController>,
1029    issue31_discovery_generation: Option<u64>,
1030    issue31_discovery_expires_at: Option<u64>,
1031    issue31_discovery_outbox: Option<Event>,
1032    issue31_private_outbox: BTreeMap<String, PendingIssue31PrivatePublish>,
1033    issue31_relay_acknowledgements: BTreeMap<String, Vec<String>>,
1034    issue31_control_cursor: Option<String>,
1035    issue31_projection_cursor: Option<String>,
1036    issue31_quarantined_events: BTreeMap<String, String>,
1037    /// The last coverage statement published to each `grant_ref:generation`,
1038    /// so a re-run that observed the same world does not republish it with only
1039    /// a new timestamp.
1040    issue31_withheld_emissions: BTreeMap<String, (String, Vec<Issue31WithheldSourceCount>)>,
1041    /// The digest of the last omega#47 publication sent to each
1042    /// `grant_ref:generation`, so a pump run that observed the same host state
1043    /// does not re-send the same snapshot with only a fresh timestamp.
1044    issue31_host_adjunct_emissions: BTreeMap<String, String>,
1045    /// The live Full Auto reading, supplied by whoever holds the supervisor.
1046    /// Absent means this host publishes no omega#47 records at all.
1047    issue31_host_projection_source: Option<Issue31HostProjectionSource>,
1048    /// Host-owned provider connection handoffs (omega#91). Durable: this is the
1049    /// record, not a cache of one.
1050    issue31_provider_handoffs: Issue31ProviderHandoffLedger,
1051    /// How the host reads its own provider roster when deciding a handoff.
1052    issue31_provider_roster_source: Option<Issue31ProviderRosterSource>,
1053    issue31_state_path: Option<PathBuf>,
1054    #[cfg(test)]
1055    issue31_fail_commit_after: Cell<Option<usize>>,
1056}
1057
1058#[derive(Clone)]
1059struct PendingIssue31PrivatePublish {
1060    rumor_event_id: String,
1061    gift_wraps: Vec<Event>,
1062}
1063
1064struct EnqueuedIssue31PrivateRecord {
1065    rumor_event_id: String,
1066    outbox_ref: String,
1067    inserted: bool,
1068}
1069
1070const ISSUE31_DURABLE_STATE_SCHEMA: &str = "openagents.omega.issue31.host_state.v1";
1071const ISSUE31_DURABLE_STATE_MAX_BYTES: u64 = 16 * 1024 * 1024;
1072const ISSUE31_NOSTR_HOST_GENERATION: u64 = 1;
1073
1074#[derive(Serialize, Deserialize)]
1075#[serde(rename_all = "camelCase", deny_unknown_fields)]
1076struct DurableIssue31HostState {
1077    schema: String,
1078    controller: Issue31HostController,
1079    discovery_generation: Option<u64>,
1080    discovery_expires_at: Option<u64>,
1081    discovery_event_json: Option<String>,
1082    private_outbox: BTreeMap<String, DurableIssue31PrivatePublish>,
1083    relay_acknowledgements: BTreeMap<String, Vec<String>>,
1084    control_cursor: Option<String>,
1085    #[serde(default)]
1086    projection_cursor: Option<String>,
1087    #[serde(default)]
1088    quarantined_events: BTreeMap<String, String>,
1089    #[serde(default)]
1090    host_adjunct_emissions: BTreeMap<String, String>,
1091    /// omega#91. `#[serde(default)]` so a state file written before handoffs
1092    /// existed still loads — as an empty ledger, which is the truth about a
1093    /// host that never had one, not a set of rows invented at load.
1094    #[serde(default)]
1095    provider_handoffs: Issue31ProviderHandoffLedger,
1096    command_results: BTreeMap<String, (String, Value)>,
1097    #[serde(default)]
1098    active_turn_ref: Option<String>,
1099    #[serde(default = "default_run_state")]
1100    run_state: String,
1101    #[serde(default)]
1102    message_seq: u64,
1103}
1104
1105#[derive(Serialize, Deserialize)]
1106#[serde(rename_all = "camelCase", deny_unknown_fields)]
1107struct DurableIssue31PrivatePublish {
1108    rumor_event_id: String,
1109    gift_wrap_event_json: Vec<String>,
1110}
1111
1112impl SarahConversationClient {
1113    /// Local/dev client with an in-memory mock relay (no network).
1114    pub fn new_mock(mut config: SarahConversationConfig) -> Self {
1115        let signer = SigningIdentity::generate();
1116        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
1117        Self::with_relay(config, Box::new(MockRelayAdapter::new()), signer)
1118    }
1119
1120    pub fn with_relay(
1121        config: SarahConversationConfig,
1122        relay: Box<dyn RelayTransport>,
1123        signer: SigningIdentity,
1124    ) -> Self {
1125        Self {
1126            config,
1127            relay,
1128            signer: ConversationSigner::Keys(signer),
1129            pending_events: VecDeque::new(),
1130            active_turn_ref: None,
1131            run_state: "idle".to_string(),
1132            message_seq: 0,
1133            command_results: BTreeMap::new(),
1134            last_gap_state: GapState::None,
1135            last_confirmed_cursor: None,
1136            issue31_host: None,
1137            issue31_discovery_generation: None,
1138            issue31_discovery_expires_at: None,
1139            issue31_discovery_outbox: None,
1140            issue31_private_outbox: BTreeMap::new(),
1141            issue31_relay_acknowledgements: BTreeMap::new(),
1142            issue31_control_cursor: None,
1143            issue31_projection_cursor: None,
1144            issue31_quarantined_events: BTreeMap::new(),
1145            issue31_withheld_emissions: BTreeMap::new(),
1146            issue31_host_adjunct_emissions: BTreeMap::new(),
1147            issue31_host_projection_source: None,
1148            issue31_provider_handoffs: Issue31ProviderHandoffLedger::default(),
1149            issue31_provider_roster_source: None,
1150            issue31_state_path: None,
1151            #[cfg(test)]
1152            issue31_fail_commit_after: Cell::new(None),
1153        }
1154    }
1155
1156    pub fn new_production(
1157        mut config: SarahConversationConfig,
1158        relay_urls: Vec<String>,
1159        identity_service: Arc<IdentityService>,
1160    ) -> Result<Self, SarahConversationError> {
1161        let owner_public_key_hex = identity_service
1162            .inspect()
1163            .map_err(|error| SarahConversationError::Identity(error.to_string()))?
1164            .identity
1165            .map(|identity| identity.public_key_hex().as_str().to_string())
1166            .ok_or(SarahConversationError::IdentityRequired)?;
1167        config.identity.owner_public_key_hex = owner_public_key_hex;
1168        config.relay_url = relay_urls.first().cloned();
1169        let mut relay = crate::nostr_websocket_relay::WebSocketRelayAdapter::new(
1170            relay_urls,
1171            identity_service.clone(),
1172            config.identity.sarah_public_key_hex.clone(),
1173            config.community_group_ids.clone(),
1174            config.community_public_key_hexes.clone(),
1175        )?;
1176        let relay_urls = relay.relay_urls().to_vec();
1177        let host_configuration = Issue31HostConfiguration {
1178            host_ref: "omega.host.local".into(),
1179            host_public_key_hex: config.identity.owner_public_key_hex.clone(),
1180            sarah_public_key_hex: config.identity.sarah_public_key_hex.clone(),
1181            conversation: config.conversation_ref(),
1182            display_name: "Local Omega".into(),
1183            relay_urls,
1184            generation: ISSUE31_NOSTR_HOST_GENERATION,
1185        };
1186        let issue31_state_path = paths::data_dir()
1187            .join("openagents")
1188            .join("issue31-nostr-host-state.json");
1189        let persisted = load_issue31_host_state(&issue31_state_path, &host_configuration)?;
1190        let mut issue31_host = match &persisted {
1191            Some(persisted) => persisted.controller.clone(),
1192            None => Issue31HostController::new(host_configuration).map_err(issue31_error)?,
1193        };
1194        issue31_host
1195            .set_admitted_device_policy(
1196                config.admitted_device_public_key_hexes.clone(),
1197                config.approved_device_scopes.clone(),
1198            )
1199            .map_err(issue31_error)?;
1200        let issue31_discovery_generation = persisted
1201            .as_ref()
1202            .and_then(|persisted| persisted.discovery_generation);
1203        let issue31_discovery_expires_at = persisted
1204            .as_ref()
1205            .and_then(|persisted| persisted.discovery_expires_at);
1206        let issue31_discovery_outbox = persisted
1207            .as_ref()
1208            .and_then(|persisted| persisted.discovery_event_json.as_deref())
1209            .map(Event::from_json)
1210            .transpose()
1211            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
1212        let issue31_private_outbox = persisted
1213            .as_ref()
1214            .map(|persisted| {
1215                durable_private_outbox_into_runtime(
1216                    persisted
1217                        .private_outbox
1218                        .iter()
1219                        .map(|(key, value)| {
1220                            (
1221                                key.clone(),
1222                                DurableIssue31PrivatePublish {
1223                                    rumor_event_id: value.rumor_event_id.clone(),
1224                                    gift_wrap_event_json: value.gift_wrap_event_json.clone(),
1225                                },
1226                            )
1227                        })
1228                        .collect(),
1229                )
1230            })
1231            .transpose()?
1232            .unwrap_or_default();
1233        let command_results = persisted
1234            .as_ref()
1235            .map(|persisted| persisted.command_results.clone())
1236            .unwrap_or_default();
1237        // omega#91: a handoff that was in flight when the last host process
1238        // ended is settled here, at load, before anything else can read the
1239        // ledger. The isolated provider home and the login it was driving died
1240        // with that process, so the terminal answer is that the handoff was
1241        // interrupted. This can only under-claim — it never reports a
1242        // connection the host did not make — and it is what stops a restart
1243        // leaving the phone with a request that neither resolves nor fails.
1244        let mut issue31_provider_handoffs = persisted
1245            .as_ref()
1246            .map(|persisted| persisted.provider_handoffs.clone())
1247            .unwrap_or_default();
1248        issue31_provider_handoffs.adopt_after_restart();
1249        let issue31_control_cursor = persisted
1250            .as_ref()
1251            .and_then(|persisted| persisted.control_cursor.clone());
1252        let issue31_projection_cursor = persisted
1253            .as_ref()
1254            .and_then(|persisted| persisted.projection_cursor.clone());
1255        let issue31_quarantined_events = persisted
1256            .as_ref()
1257            .map(|persisted| persisted.quarantined_events.clone())
1258            .unwrap_or_default();
1259        let issue31_host_adjunct_emissions = persisted
1260            .as_ref()
1261            .map(|persisted| persisted.host_adjunct_emissions.clone())
1262            .unwrap_or_default();
1263        let active_turn_ref = persisted
1264            .as_ref()
1265            .and_then(|persisted| persisted.active_turn_ref.clone());
1266        let run_state = persisted
1267            .as_ref()
1268            .map(|persisted| persisted.run_state.clone())
1269            .unwrap_or_else(default_run_state);
1270        let message_seq = persisted
1271            .as_ref()
1272            .map(|persisted| persisted.message_seq)
1273            .unwrap_or_default();
1274        let issue31_relay_acknowledgements = persisted
1275            .map(|persisted| persisted.relay_acknowledgements)
1276            .unwrap_or_default();
1277        for (event_id, acknowledged_relays) in &issue31_relay_acknowledgements {
1278            relay.restore_publication_acknowledgements(event_id, acknowledged_relays);
1279        }
1280        Ok(Self {
1281            config,
1282            relay: Box::new(relay),
1283            signer: ConversationSigner::OmegaIdentity(identity_service),
1284            pending_events: VecDeque::new(),
1285            active_turn_ref,
1286            run_state,
1287            message_seq,
1288            command_results,
1289            last_gap_state: GapState::None,
1290            last_confirmed_cursor: None,
1291            issue31_host: Some(issue31_host),
1292            issue31_discovery_generation,
1293            issue31_discovery_expires_at,
1294            issue31_discovery_outbox,
1295            issue31_private_outbox,
1296            issue31_relay_acknowledgements,
1297            issue31_control_cursor,
1298            issue31_projection_cursor,
1299            issue31_quarantined_events,
1300            issue31_withheld_emissions: BTreeMap::new(),
1301            issue31_host_adjunct_emissions,
1302            issue31_host_projection_source: None,
1303            issue31_provider_handoffs,
1304            issue31_provider_roster_source: None,
1305            issue31_state_path: Some(issue31_state_path),
1306            #[cfg(test)]
1307            issue31_fail_commit_after: Cell::new(None),
1308        })
1309    }
1310
1311    /// Build a client that exercises NIP-42 against a mock that requires AUTH.
1312    pub fn mock_with_nip42_auth(
1313        mut config: SarahConversationConfig,
1314        challenge: &str,
1315        signer: SigningIdentity,
1316    ) -> Self {
1317        config.relay_url = Some("wss://relay.openagents.com".to_string());
1318        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
1319        Self::with_relay(
1320            config,
1321            Box::new(MockRelayAdapter::with_required_auth(challenge)),
1322            signer,
1323        )
1324    }
1325
1326    pub fn generation(&self) -> u64 {
1327        self.config.generation
1328    }
1329
1330    pub fn synchronize_process_generation(
1331        &mut self,
1332        generation: u64,
1333    ) -> Result<(), SarahConversationError> {
1334        if generation == 0 || generation < self.config.generation {
1335            return Err(SarahConversationError::StaleGeneration {
1336                expected: self.config.generation,
1337                got: generation,
1338            });
1339        }
1340        self.config.generation = generation;
1341        Ok(())
1342    }
1343
1344    pub fn conversation_ref(&self) -> String {
1345        self.config.conversation_ref()
1346    }
1347
1348    /// Handle a framed request method. Returns the result object (not the frame).
1349    pub fn handle_request(
1350        &mut self,
1351        method: &str,
1352        generation: u64,
1353        params: Option<&Value>,
1354    ) -> Result<Value, SarahConversationError> {
1355        self.ensure_generation(generation)?;
1356        match method {
1357            SARAH_METHOD_SESSION_STATUS => Ok(serde_json::to_value(self.session_status()?)
1358                .map_err(|error| SarahConversationError::Internal(error.to_string()))?),
1359            SARAH_METHOD_BOOTSTRAP => Ok(serde_json::to_value(self.bootstrap()?)
1360                .map_err(|error| SarahConversationError::Internal(error.to_string()))?),
1361            SARAH_METHOD_ROOM_SNAPSHOT => {
1362                let legacy_cursor = params
1363                    .and_then(|value| value.get("cursor"))
1364                    .and_then(Value::as_str);
1365                let legacy_limit = params
1366                    .and_then(|value| value.get("limit"))
1367                    .and_then(Value::as_u64)
1368                    .map(|value| value as usize);
1369                let transcript_cursor = params
1370                    .and_then(|value| value.get("transcriptCursor"))
1371                    .and_then(Value::as_str)
1372                    .or(legacy_cursor);
1373                let activity_cursor = params
1374                    .and_then(|value| value.get("activityCursor"))
1375                    .and_then(Value::as_str)
1376                    .or(legacy_cursor);
1377                let nostr_cursor = params
1378                    .and_then(|value| value.get("nostrCursor"))
1379                    .and_then(Value::as_str)
1380                    .or(legacy_cursor);
1381                let transcript_limit = params
1382                    .and_then(|value| value.get("transcriptLimit"))
1383                    .and_then(Value::as_u64)
1384                    .map(|value| value as usize)
1385                    .or(legacy_limit);
1386                let activity_limit = params
1387                    .and_then(|value| value.get("activityLimit"))
1388                    .and_then(Value::as_u64)
1389                    .map(|value| value as usize)
1390                    .or(legacy_limit);
1391                let nostr_limit = params
1392                    .and_then(|value| value.get("nostrLimit"))
1393                    .and_then(Value::as_u64)
1394                    .map(|value| value as usize)
1395                    .or(legacy_limit);
1396                Ok(serde_json::to_value(self.room_snapshot_with_record_cursor(
1397                    transcript_cursor,
1398                    transcript_limit,
1399                    activity_cursor,
1400                    activity_limit,
1401                    nostr_cursor,
1402                    nostr_limit,
1403                )?)
1404                .map_err(|error| SarahConversationError::Internal(error.to_string()))?)
1405            }
1406            SARAH_METHOD_SEND_MESSAGE => {
1407                let (idempotency_ref, expected_generation) = command_binding(params, generation)?;
1408                let text = params
1409                    .and_then(|value| value.get("text"))
1410                    .and_then(Value::as_str)
1411                    .ok_or_else(|| {
1412                        SarahConversationError::InvalidRequest(
1413                            "sarah_send_message requires text".into(),
1414                        )
1415                    })?;
1416                let fingerprint = command_fingerprint(method, params)?;
1417                if let Some(cached) = self.cached_command(&idempotency_ref, &fingerprint)? {
1418                    self.retry_durable_outbox()?;
1419                    return Ok(cached);
1420                }
1421                let result = self.send_message_with_fingerprint(
1422                    text,
1423                    &idempotency_ref,
1424                    expected_generation,
1425                    Some(fingerprint),
1426                )?;
1427                serde_json::to_value(result)
1428                    .map_err(|error| SarahConversationError::Internal(error.to_string()))
1429            }
1430            SARAH_METHOD_INTERRUPT_TURN => {
1431                let (idempotency_ref, expected_generation) = command_binding(params, generation)?;
1432                let turn_ref = params
1433                    .and_then(|value| value.get("turnRef"))
1434                    .and_then(Value::as_str)
1435                    .map(str::to_owned)
1436                    .or_else(|| self.active_turn_ref.clone())
1437                    .ok_or_else(|| {
1438                        SarahConversationError::InvalidRequest(
1439                            "sarah_interrupt_turn requires turnRef".into(),
1440                        )
1441                    })?;
1442                let fingerprint = command_fingerprint(method, params)?;
1443                if let Some(cached) = self.cached_command(&idempotency_ref, &fingerprint)? {
1444                    self.retry_durable_outbox()?;
1445                    return Ok(cached);
1446                }
1447                let result = self.interrupt_turn_with_fingerprint(
1448                    &turn_ref,
1449                    &idempotency_ref,
1450                    expected_generation,
1451                    Some(fingerprint),
1452                )?;
1453                serde_json::to_value(result)
1454                    .map_err(|error| SarahConversationError::Internal(error.to_string()))
1455            }
1456            SARAH_METHOD_DEVICE_GRANTS => {
1457                self.sync_issue31_host()?;
1458                let controller = self.issue31_host.as_ref().ok_or_else(|| {
1459                    SarahConversationError::InvalidRequest("Issue 31 host is not configured".into())
1460                })?;
1461                let grants = controller
1462                    .grant_projections(unix_now())
1463                    .map_err(issue31_error)?;
1464                Ok(json!({ "grants": grants }))
1465            }
1466            SARAH_METHOD_RENEW_DEVICE_GRANT => {
1467                let (idempotency_ref, expected_generation) = command_binding(params, generation)?;
1468                self.ensure_generation(expected_generation)?;
1469                let grant_ref = params
1470                    .and_then(|value| value.get("grantRef"))
1471                    .and_then(Value::as_str)
1472                    .ok_or_else(|| {
1473                        SarahConversationError::InvalidRequest(
1474                            "sarah_renew_device_grant requires grantRef".into(),
1475                        )
1476                    })?;
1477                let scopes = params
1478                    .and_then(|value| value.get("scopes"))
1479                    .cloned()
1480                    .ok_or_else(|| {
1481                        SarahConversationError::InvalidRequest(
1482                            "sarah_renew_device_grant requires scopes".into(),
1483                        )
1484                    })?;
1485                let scopes = serde_json::from_value(scopes)
1486                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
1487                let expires_at = params
1488                    .and_then(|value| value.get("expiresAt"))
1489                    .and_then(Value::as_u64)
1490                    .ok_or_else(|| {
1491                        SarahConversationError::InvalidRequest(
1492                            "sarah_renew_device_grant requires expiresAt".into(),
1493                        )
1494                    })?;
1495                let fingerprint = command_fingerprint(method, params)?;
1496                if let Some(cached) = self.cached_command(&idempotency_ref, &fingerprint)? {
1497                    self.retry_durable_outbox()?;
1498                    return Ok(cached);
1499                }
1500                self.renew_issue31_grant(
1501                    grant_ref,
1502                    scopes,
1503                    expires_at,
1504                    idempotency_ref,
1505                    fingerprint,
1506                )
1507            }
1508            SARAH_METHOD_REVOKE_DEVICE_GRANT => {
1509                let (idempotency_ref, expected_generation) = command_binding(params, generation)?;
1510                self.ensure_generation(expected_generation)?;
1511                let grant_ref = params
1512                    .and_then(|value| value.get("grantRef"))
1513                    .and_then(Value::as_str)
1514                    .ok_or_else(|| {
1515                        SarahConversationError::InvalidRequest(
1516                            "sarah_revoke_device_grant requires grantRef".into(),
1517                        )
1518                    })?;
1519                let reason_ref = params
1520                    .and_then(|value| value.get("reasonRef"))
1521                    .and_then(Value::as_str)
1522                    .map(str::to_owned);
1523                let fingerprint = command_fingerprint(method, params)?;
1524                if let Some(cached) = self.cached_command(&idempotency_ref, &fingerprint)? {
1525                    self.retry_durable_outbox()?;
1526                    return Ok(cached);
1527                }
1528                self.revoke_issue31_grant(grant_ref, reason_ref, idempotency_ref, fingerprint)
1529            }
1530            SARAH_METHOD_READMIT_DEVICE => {
1531                let (idempotency_ref, expected_generation) = command_binding(params, generation)?;
1532                self.ensure_generation(expected_generation)?;
1533                let grant_ref = params
1534                    .and_then(|value| value.get("grantRef"))
1535                    .and_then(Value::as_str)
1536                    .ok_or_else(|| {
1537                        SarahConversationError::InvalidRequest(
1538                            "sarah_readmit_device requires grantRef".into(),
1539                        )
1540                    })?;
1541                let fingerprint = command_fingerprint(method, params)?;
1542                if let Some(cached) = self.cached_command(&idempotency_ref, &fingerprint)? {
1543                    self.retry_durable_outbox()?;
1544                    return Ok(cached);
1545                }
1546                self.readmit_issue31_device(grant_ref, idempotency_ref, fingerprint)
1547            }
1548            _ => Err(SarahConversationError::InvalidRequest(format!(
1549                "unknown Sarah method {method}"
1550            ))),
1551        }
1552    }
1553
1554    pub fn session_status(&mut self) -> Result<SessionStatusResult, SarahConversationError> {
1555        self.ensure_connected()?;
1556        Ok(SessionStatusResult {
1557            signed_in: matches!(
1558                self.config.identity.binding_state,
1559                BindingState::Bound | BindingState::Unbound
1560            ) && !self.config.identity.owner_public_key_hex.is_empty(),
1561            account_label: self.config.identity.account_label.clone(),
1562            binding_state: self.config.identity.binding_state,
1563            owner_public_key_hex: Some(self.config.identity.owner_public_key_hex.clone()),
1564            binding_expires_at: None,
1565            transport: self.transport_label(),
1566        })
1567    }
1568
1569    pub fn bootstrap(&mut self) -> Result<BootstrapResult, SarahConversationError> {
1570        self.ensure_connected()?;
1571        self.sync_issue31_host()?;
1572        let room_state = self.current_room_state();
1573        self.push_room_state_event(&room_state);
1574        Ok(BootstrapResult {
1575            principal_ref: "principal.sarah".to_string(),
1576            display_name: "Sarah".to_string(),
1577            role: "owner_orchestrator".to_string(),
1578            conversation_ref: self.config.conversation_ref(),
1579            legacy_thread_ref: self.config.legacy_thread_ref(),
1580            owner_public_key_hex: self.config.identity.owner_public_key_hex.clone(),
1581            sarah_public_key_hex: self.config.identity.sarah_public_key_hex.clone(),
1582            authority_profile_ref: "docs/authority/SARAH_AUTHORITY.md".to_string(),
1583            authority_profile_revision: 7,
1584            admitted_device_fingerprints: self
1585                .issue31_host
1586                .as_ref()
1587                .map(Issue31HostController::admitted_device_fingerprints)
1588                .unwrap_or_default(),
1589            quarantined_issue31_event_count: self.issue31_quarantined_events.len(),
1590            room_state,
1591        })
1592    }
1593
1594    pub fn sync_issue31_host(&mut self) -> Result<(), SarahConversationError> {
1595        self.ensure_connected()?;
1596        let Some(mut controller) = self.issue31_host.take() else {
1597            return Ok(());
1598        };
1599        let result = self.sync_issue31_host_with(&mut controller);
1600        self.issue31_host = Some(controller);
1601        let persistence = self.persist_issue31_host_state();
1602        finish_durable_operation(result, persistence)
1603    }
1604
1605    fn renew_issue31_grant(
1606        &mut self,
1607        grant_ref: &str,
1608        scopes: Vec<crate::Issue31PairingScope>,
1609        expires_at: u64,
1610        idempotency_ref: String,
1611        fingerprint: String,
1612    ) -> Result<Value, SarahConversationError> {
1613        self.ensure_connected()?;
1614        self.ensure_command_result_capacity(&idempotency_ref)?;
1615        let mut controller = self.issue31_host.take().ok_or_else(|| {
1616            SarahConversationError::InvalidRequest("Issue 31 host is not configured".into())
1617        })?;
1618        let result = (|| {
1619            let record = controller
1620                .renew_grant(grant_ref, scopes, unix_now(), expires_at)
1621                .map_err(issue31_error)?;
1622            let enqueued = self.enqueue_issue31_pairing_record(&record)?;
1623            let event_id = enqueued.rumor_event_id.clone();
1624            controller
1625                .record_emitted_pairing(event_id.clone(), record)
1626                .map_err(issue31_error)?;
1627            let previous_projection_cursor = self.issue31_projection_cursor.take();
1628            let response = json!({ "accepted": true, "eventId": event_id, "grantRef": grant_ref });
1629            self.command_results.insert(
1630                idempotency_ref.clone(),
1631                (fingerprint.clone(), response.clone()),
1632            );
1633            if let Err(error) = self.persist_issue31_host_state_with_controller(&controller) {
1634                self.issue31_projection_cursor = previous_projection_cursor;
1635                self.command_results.remove(&idempotency_ref);
1636                self.rollback_issue31_enqueue(&enqueued);
1637                return Err(error);
1638            }
1639            self.flush_issue31_outbox()?;
1640            self.persist_issue31_host_state_with_controller(&controller)?;
1641            Ok(response)
1642        })();
1643        self.issue31_host = Some(controller);
1644        let persistence = self.persist_issue31_host_state();
1645        finish_durable_operation(result, persistence)
1646    }
1647
1648    fn revoke_issue31_grant(
1649        &mut self,
1650        grant_ref: &str,
1651        reason_ref: Option<String>,
1652        idempotency_ref: String,
1653        fingerprint: String,
1654    ) -> Result<Value, SarahConversationError> {
1655        self.ensure_connected()?;
1656        self.ensure_command_result_capacity(&idempotency_ref)?;
1657        let mut controller = self.issue31_host.take().ok_or_else(|| {
1658            SarahConversationError::InvalidRequest("Issue 31 host is not configured".into())
1659        })?;
1660        let result = (|| {
1661            let record = controller
1662                .revoke_grant(grant_ref, unix_now(), reason_ref)
1663                .map_err(issue31_error)?;
1664            let enqueued = self.enqueue_issue31_pairing_record(&record)?;
1665            let event_id = enqueued.rumor_event_id.clone();
1666            controller
1667                .record_emitted_pairing(event_id.clone(), record)
1668                .map_err(issue31_error)?;
1669            let response = json!({ "accepted": true, "eventId": event_id, "grantRef": grant_ref });
1670            self.command_results.insert(
1671                idempotency_ref.clone(),
1672                (fingerprint.clone(), response.clone()),
1673            );
1674            if let Err(error) = self.persist_issue31_host_state_with_controller(&controller) {
1675                self.command_results.remove(&idempotency_ref);
1676                self.rollback_issue31_enqueue(&enqueued);
1677                return Err(error);
1678            }
1679            self.flush_issue31_outbox()?;
1680            self.persist_issue31_host_state_with_controller(&controller)?;
1681            Ok(response)
1682        })();
1683        self.issue31_host = Some(controller);
1684        let persistence = self.persist_issue31_host_state();
1685        finish_durable_operation(result, persistence)
1686    }
1687
1688    /// Clear the revocations blocking the device behind `grant_ref`.
1689    ///
1690    /// This publishes nothing. Re-admission is host-local owner policy, not a
1691    /// record other peers are entitled to act on: the device still has to run a
1692    /// fresh, signed pairing handshake to obtain a new grant, and that grant is
1693    /// the only thing that carries authority.
1694    fn readmit_issue31_device(
1695        &mut self,
1696        grant_ref: &str,
1697        idempotency_ref: String,
1698        fingerprint: String,
1699    ) -> Result<Value, SarahConversationError> {
1700        self.ensure_connected()?;
1701        self.ensure_command_result_capacity(&idempotency_ref)?;
1702        let controller = self.issue31_host.take().ok_or_else(|| {
1703            SarahConversationError::InvalidRequest("Issue 31 host is not configured".into())
1704        })?;
1705        // Mutate a candidate so a refused or unpersistable re-admission cannot
1706        // leave a cleared revocation behind in the live controller.
1707        let mut candidate = controller.clone();
1708        let result = (|| {
1709            let cleared = candidate
1710                .readmit_device_for_grant(grant_ref)
1711                .map_err(issue31_error)?;
1712            let response = json!({
1713                "accepted": true,
1714                "grantRef": grant_ref,
1715                "clearedRevocations": cleared.len(),
1716            });
1717            self.command_results.insert(
1718                idempotency_ref.clone(),
1719                (fingerprint.clone(), response.clone()),
1720            );
1721            if let Err(error) = self.persist_issue31_host_state_with_controller(&candidate) {
1722                self.command_results.remove(&idempotency_ref);
1723                return Err(error);
1724            }
1725            Ok(response)
1726        })();
1727        self.issue31_host = Some(if result.is_ok() { candidate } else { controller });
1728        let persistence = self.persist_issue31_host_state();
1729        finish_durable_operation(result, persistence)
1730    }
1731
1732    fn sync_issue31_host_with(
1733        &mut self,
1734        controller: &mut Issue31HostController,
1735    ) -> Result<(), SarahConversationError> {
1736        self.flush_issue31_outbox()?;
1737        self.persist_issue31_host_state_with_controller(controller)?;
1738        let now = unix_now();
1739        let discovery = controller
1740            .discovery_v2(now, now.saturating_add(24 * 60 * 60))
1741            .map_err(issue31_error)?;
1742        if self.issue31_discovery_generation != Some(discovery.generation)
1743            || self
1744                .issue31_discovery_expires_at
1745                .is_none_or(|expires_at| expires_at <= now.saturating_add(60 * 60))
1746        {
1747            let replacement = self.sign_issue31_discovery(&discovery)?;
1748            let previous_outbox = self.issue31_discovery_outbox.replace(replacement);
1749            let previous_generation = self.issue31_discovery_generation;
1750            let previous_expires_at = self.issue31_discovery_expires_at;
1751            let previous_acknowledgements = previous_outbox.as_ref().and_then(|event| {
1752                self.issue31_relay_acknowledgements
1753                    .remove(&event.id.to_hex())
1754                    .map(|acknowledgements| (event.id.to_hex(), acknowledgements))
1755            });
1756            self.issue31_discovery_generation = Some(discovery.generation);
1757            self.issue31_discovery_expires_at = Some(discovery.expires_at);
1758            if let Err(error) = self.persist_issue31_host_state_with_controller(controller) {
1759                self.issue31_discovery_outbox = previous_outbox;
1760                self.issue31_discovery_generation = previous_generation;
1761                self.issue31_discovery_expires_at = previous_expires_at;
1762                if let Some((event_id, acknowledgements)) = previous_acknowledgements {
1763                    self.issue31_relay_acknowledgements
1764                        .insert(event_id, acknowledgements);
1765                }
1766                return Err(error);
1767            }
1768            self.flush_issue31_outbox()?;
1769            self.persist_issue31_host_state_with_controller(controller)?;
1770        }
1771
1772        let conversation_ref = self.config.conversation_ref();
1773        let mut cursor = self.issue31_control_cursor.clone();
1774        let mut last_scanned_cursor = None;
1775        let mut scan_exhausted = false;
1776        let mut records = Vec::new();
1777        for _ in 0..8 {
1778            let page =
1779                self.query_with_auth(&conversation_ref, cursor.as_deref(), MAX_PAGE_LIMIT)?;
1780            self.last_gap_state = strongest_gap_state(self.last_gap_state, page.gap_state);
1781            if let Some(page_cursor) = page.events.last().map(stored_event_cursor) {
1782                last_scanned_cursor = Some(page_cursor);
1783            }
1784            for event in page.events {
1785                if event.record_kind == "pairing" || event.record_kind == "control" {
1786                    records.push(event);
1787                }
1788            }
1789            let Some(next_cursor) = page.next_cursor else {
1790                scan_exhausted = true;
1791                break;
1792            };
1793            cursor = Some(next_cursor);
1794        }
1795
1796        // omega#91: one roster observation per pass, taken BEFORE this pass's
1797        // commands are handled. A handoff opened by a command in this pass is
1798        // therefore published as `requested` and only looked at against the
1799        // roster on the next pass. Advancing after would mean the host bound a
1800        // handoff using a roster reading older than the request itself, and the
1801        // phone would never see the handoff appear before it bound.
1802        self.advance_issue31_provider_handoffs(now);
1803        for event in records {
1804            if self
1805                .issue31_quarantined_events
1806                .contains_key(&event.event_id)
1807            {
1808                continue;
1809            }
1810            if event.record_kind == "pairing" {
1811                let record = match Issue31PairingRecord::decode(event.content_summary.as_bytes()) {
1812                    Ok(record) => record,
1813                    Err(_) => {
1814                        self.quarantine_issue31_event(
1815                            &event.event_id,
1816                            "reason.omega.invalid_pairing_record",
1817                            controller,
1818                        )?;
1819                        continue;
1820                    }
1821                };
1822                let mut candidate = controller.clone();
1823                let outbound = match candidate.handle_pairing_event(
1824                    Issue31PairingEvent {
1825                        event_id: event.event_id.clone(),
1826                        record,
1827                    },
1828                    now,
1829                ) {
1830                    Ok(outbound) => outbound,
1831                    Err(_) => {
1832                        self.quarantine_issue31_event(
1833                            &event.event_id,
1834                            "reason.omega.pairing_rejected",
1835                            controller,
1836                        )?;
1837                        continue;
1838                    }
1839                };
1840                if let Some(outbound) = outbound {
1841                    let resets_projection_cursor = matches!(
1842                        outbound,
1843                        Issue31PairingRecord::ScopedGrant { .. }
1844                            | Issue31PairingRecord::GrantRenewal { .. }
1845                    );
1846                    let enqueued = self.enqueue_issue31_pairing_record(&outbound)?;
1847                    if let Err(error) =
1848                        candidate.record_emitted_pairing(enqueued.rumor_event_id.clone(), outbound)
1849                    {
1850                        self.rollback_issue31_enqueue(&enqueued);
1851                        return Err(issue31_error(error));
1852                    }
1853                    let previous_projection_cursor = resets_projection_cursor
1854                        .then(|| self.issue31_projection_cursor.take())
1855                        .flatten();
1856                    if let Err(error) = self.persist_issue31_host_state_with_controller(&candidate)
1857                    {
1858                        if resets_projection_cursor {
1859                            self.issue31_projection_cursor = previous_projection_cursor;
1860                        }
1861                        self.rollback_issue31_enqueue(&enqueued);
1862                        return Err(error);
1863                    }
1864                    *controller = candidate;
1865                    self.flush_issue31_outbox()?;
1866                    self.persist_issue31_host_state_with_controller(controller)?;
1867                } else {
1868                    self.persist_issue31_host_state_with_controller(&candidate)?;
1869                    *controller = candidate;
1870                }
1871            } else {
1872                let command_schema = serde_json::from_str::<Value>(&event.content_summary)
1873                    .ok()
1874                    .and_then(|value| value.get("schema")?.as_str().map(str::to_owned));
1875                if command_schema.as_deref() == Some(ISSUE31_COMMAND_SCHEMA_V2) {
1876                    let record =
1877                        match Issue31CommandRecordV2::decode(event.content_summary.as_bytes()) {
1878                            Ok(record) => record,
1879                            Err(_) => {
1880                                self.quarantine_issue31_event(
1881                                    &event.event_id,
1882                                    "reason.omega.invalid_command_record",
1883                                    controller,
1884                                )?;
1885                                continue;
1886                            }
1887                        };
1888                    let mut candidate = controller.clone();
1889                    let result = match candidate.handle_command_event_v2(
1890                        event.event_id.clone(),
1891                        record,
1892                        now,
1893                        |arguments,
1894                         idempotency_ref,
1895                         grant_ref,
1896                         device_public_key_hex,
1897                         expected_generation| {
1898                            self.issue31_host = Some(controller.clone());
1899                            let execution = self.execute_issue31_action_v2(
1900                                arguments,
1901                                idempotency_ref,
1902                                grant_ref,
1903                                device_public_key_hex,
1904                                expected_generation,
1905                            );
1906                            self.issue31_host.take();
1907                            execution
1908                        },
1909                    ) {
1910                        Ok(result) => result,
1911                        Err(_) => {
1912                            self.quarantine_issue31_event(
1913                                &event.event_id,
1914                                "reason.omega.command_rejected",
1915                                controller,
1916                            )?;
1917                            continue;
1918                        }
1919                    };
1920                    if let Some(result) = result {
1921                        if let Issue31CommandRecordV2::CommandResult {
1922                            status: Issue31CommandHandlingStatus::Accepted,
1923                            grant_ref,
1924                            expected_generation,
1925                            source_event_id: Some(source_event_id),
1926                            ..
1927                        } = &result
1928                        {
1929                            candidate
1930                                .record_source_projection(
1931                                    grant_ref.clone(),
1932                                    *expected_generation,
1933                                    source_event_id.clone(),
1934                                )
1935                                .map_err(issue31_error)?;
1936                        }
1937                        let enqueued = self.enqueue_issue31_command_record_v2(&result)?;
1938                        if let Err(error) =
1939                            self.persist_issue31_host_state_with_controller(&candidate)
1940                        {
1941                            self.rollback_issue31_enqueue(&enqueued);
1942                            return Err(error);
1943                        }
1944                        *controller = candidate;
1945                        self.flush_issue31_outbox()?;
1946                        self.persist_issue31_host_state_with_controller(controller)?;
1947                    } else {
1948                        self.persist_issue31_host_state_with_controller(&candidate)?;
1949                        *controller = candidate;
1950                    }
1951                    continue;
1952                }
1953                let record = match Issue31CommandRecord::decode(event.content_summary.as_bytes()) {
1954                    Ok(record) => record,
1955                    Err(_) => {
1956                        self.quarantine_issue31_event(
1957                            &event.event_id,
1958                            "reason.omega.invalid_command_record",
1959                            controller,
1960                        )?;
1961                        continue;
1962                    }
1963                };
1964                let mut candidate = controller.clone();
1965                let result = match candidate.handle_command_event(
1966                    Issue31CommandEvent {
1967                        event_id: event.event_id.clone(),
1968                        record,
1969                    },
1970                    now,
1971                    |action_ref, arguments_ref, idempotency_ref| {
1972                        self.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
1973                    },
1974                ) {
1975                    Ok(result) => result,
1976                    Err(_) => {
1977                        self.quarantine_issue31_event(
1978                            &event.event_id,
1979                            "reason.omega.command_rejected",
1980                            controller,
1981                        )?;
1982                        continue;
1983                    }
1984                };
1985                if let Some(result) = result {
1986                    let enqueued = self.enqueue_issue31_command_record(&result)?;
1987                    if let Err(error) = self.persist_issue31_host_state_with_controller(&candidate)
1988                    {
1989                        self.rollback_issue31_enqueue(&enqueued);
1990                        return Err(error);
1991                    }
1992                    *controller = candidate;
1993                    self.flush_issue31_outbox()?;
1994                    self.persist_issue31_host_state_with_controller(controller)?;
1995                } else {
1996                    self.persist_issue31_host_state_with_controller(&candidate)?;
1997                    *controller = candidate;
1998                }
1999            }
2000        }
2001        self.project_issue31_sources(controller, now)?;
2002        // The omega#47 documents ride the same durable outbox as every other
2003        // owner-private record, so a device that was offline for this pass gets
2004        // them on the next flush rather than losing the snapshot entirely.
2005        self.publish_issue31_host_adjuncts(controller, now)?;
2006        self.persist_issue31_host_state_with_controller(controller)?;
2007        self.flush_issue31_outbox()?;
2008        self.persist_issue31_host_state_with_controller(controller)?;
2009        if let Some(last_scanned_cursor) = last_scanned_cursor {
2010            self.issue31_control_cursor = Some(last_scanned_cursor);
2011        }
2012        if !scan_exhausted {
2013            self.last_gap_state = strongest_gap_state(self.last_gap_state, GapState::Possible);
2014        }
2015        self.persist_issue31_host_state_with_controller(controller)?;
2016        Ok(())
2017    }
2018
2019    fn quarantine_issue31_event(
2020        &mut self,
2021        event_id: &str,
2022        reason_ref: &str,
2023        controller: &Issue31HostController,
2024    ) -> Result<(), SarahConversationError> {
2025        if let Some(existing) = self.issue31_quarantined_events.get(event_id) {
2026            if existing == reason_ref {
2027                return Ok(());
2028            }
2029            return Err(SarahConversationError::Internal(
2030                "Issue 31 quarantine reason changed for the same event".into(),
2031            ));
2032        }
2033        if self.issue31_quarantined_events.len() >= MAX_QUARANTINED_ISSUE31_EVENTS {
2034            return Err(SarahConversationError::Internal(
2035                "Issue 31 quarantine bound is exhausted".into(),
2036            ));
2037        }
2038        if !is_lower_hex_64(event_id) || !crate::is_issue31_public_ref(reason_ref) {
2039            return Err(SarahConversationError::Internal(
2040                "Issue 31 quarantine record is invalid".into(),
2041            ));
2042        }
2043        self.issue31_quarantined_events
2044            .insert(event_id.to_string(), reason_ref.to_string());
2045        if let Err(error) = self.persist_issue31_host_state_with_controller(controller) {
2046            self.issue31_quarantined_events.remove(event_id);
2047            return Err(error);
2048        }
2049        Ok(())
2050    }
2051
2052    fn sign_issue31_discovery(
2053        &self,
2054        discovery: &Issue31HostDiscoveryV2,
2055    ) -> Result<Event, SarahConversationError> {
2056        let content = serde_json::to_string(discovery)
2057            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2058        let tags = [
2059            ["d", discovery.host_ref.as_str()],
2060            ["k", "1059"],
2061            ["t", "omega-issue31-host"],
2062            ["alt", "Omega Issue 31 Nostr host discovery"],
2063        ]
2064        .into_iter()
2065        .map(|tag| {
2066            Tag::parse(tag)
2067                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))
2068        })
2069        .collect::<Result<Vec<_>, _>>()?;
2070        self.signer
2071            .sign_public_record(ISSUE31_HOST_DISCOVERY_KIND, &content, tags)
2072    }
2073
2074    fn enqueue_issue31_pairing_record(
2075        &mut self,
2076        record: &Issue31PairingRecord,
2077    ) -> Result<EnqueuedIssue31PrivateRecord, SarahConversationError> {
2078        let content = serde_json::to_string(record)
2079            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2080        self.enqueue_issue31_private_content(
2081            ISSUE31_PAIRING_SCHEMA,
2082            &content,
2083            record.device_public_key_hex(),
2084        )
2085    }
2086
2087    fn enqueue_issue31_command_record(
2088        &mut self,
2089        record: &Issue31CommandRecord,
2090    ) -> Result<EnqueuedIssue31PrivateRecord, SarahConversationError> {
2091        let content = serde_json::to_string(record)
2092            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2093        self.enqueue_issue31_private_content(
2094            ISSUE31_COMMAND_SCHEMA,
2095            &content,
2096            record.device_public_key_hex(),
2097        )
2098    }
2099
2100    fn enqueue_issue31_command_record_v2(
2101        &mut self,
2102        record: &Issue31CommandRecordV2,
2103    ) -> Result<EnqueuedIssue31PrivateRecord, SarahConversationError> {
2104        let content = serde_json::to_string(record)
2105            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2106        self.enqueue_issue31_private_content(
2107            ISSUE31_COMMAND_SCHEMA_V2,
2108            &content,
2109            record.device_public_key_hex(),
2110        )
2111    }
2112
2113    fn enqueue_issue31_private_content(
2114        &mut self,
2115        schema: &str,
2116        content: &str,
2117        recipient_public_key_hex: &str,
2118    ) -> Result<EnqueuedIssue31PrivateRecord, SarahConversationError> {
2119        PublicKey::from_hex(recipient_public_key_hex)
2120            .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
2121        let outbox_ref = format!(
2122            "{schema}.{:x}",
2123            Sha256::digest([content.as_bytes(), recipient_public_key_hex.as_bytes()].concat())
2124        );
2125        let tags = vec![
2126            Tag::parse(["p", recipient_public_key_hex])
2127                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
2128        ];
2129        let (rumor_event_id, inserted) = self.enqueue_private_content(
2130            &outbox_ref,
2131            content,
2132            tags,
2133            &[recipient_public_key_hex.to_string()],
2134        )?;
2135        Ok(EnqueuedIssue31PrivateRecord {
2136            rumor_event_id,
2137            outbox_ref,
2138            inserted,
2139        })
2140    }
2141
2142    fn rollback_issue31_enqueue(&mut self, enqueued: &EnqueuedIssue31PrivateRecord) {
2143        if enqueued.inserted {
2144            self.issue31_private_outbox.remove(&enqueued.outbox_ref);
2145        }
2146    }
2147
2148    fn enqueue_private_content(
2149        &mut self,
2150        outbox_ref: &str,
2151        content: &str,
2152        tags: Vec<Tag>,
2153        recipients: &[String],
2154    ) -> Result<(String, bool), SarahConversationError> {
2155        if let Some(pending) = self.issue31_private_outbox.get(outbox_ref) {
2156            return Ok((pending.rumor_event_id.clone(), false));
2157        }
2158        if self.issue31_private_outbox.len() >= MAX_PRIVATE_OUTBOX_ITEMS {
2159            return Err(SarahConversationError::Internal(
2160                "durable private outbox item bound is exhausted".into(),
2161            ));
2162        }
2163        let (rumor_event_id, gift_wraps) =
2164            self.signer.private_messages(content, tags, recipients)?;
2165        if self
2166            .issue31_relay_acknowledgements
2167            .len()
2168            .saturating_add(gift_wraps.len())
2169            > MAX_RELAY_ACKNOWLEDGEMENTS
2170        {
2171            return Err(SarahConversationError::Internal(
2172                "durable relay acknowledgement bound is exhausted".into(),
2173            ));
2174        }
2175        self.issue31_private_outbox.insert(
2176            outbox_ref.to_string(),
2177            PendingIssue31PrivatePublish {
2178                rumor_event_id: rumor_event_id.clone(),
2179                gift_wraps,
2180            },
2181        );
2182        Ok((rumor_event_id, true))
2183    }
2184
2185    fn flush_issue31_outbox(&mut self) -> Result<(), SarahConversationError> {
2186        if let Some(event) = self.issue31_discovery_outbox.clone() {
2187            self.publish_with_auth(&event)?;
2188            let event_id = event.id.to_hex();
2189            self.record_relay_acknowledgements(&event_id)?;
2190            if self.relay.publication_complete(&event_id) {
2191                self.issue31_discovery_outbox = None;
2192                self.issue31_relay_acknowledgements.remove(&event_id);
2193            }
2194        }
2195        let pending_refs: Vec<String> = self.issue31_private_outbox.keys().cloned().collect();
2196        for pending_ref in pending_refs {
2197            let pending = self
2198                .issue31_private_outbox
2199                .get(&pending_ref)
2200                .cloned()
2201                .ok_or_else(|| {
2202                    SarahConversationError::Internal("private outbox vanished".into())
2203                })?;
2204            for gift_wrap in &pending.gift_wraps {
2205                self.publish_with_auth(gift_wrap)?;
2206                let event_id = gift_wrap.id.to_hex();
2207                self.record_relay_acknowledgements(&event_id)?;
2208            }
2209            if pending
2210                .gift_wraps
2211                .iter()
2212                .all(|gift_wrap| self.relay.publication_complete(&gift_wrap.id.to_hex()))
2213            {
2214                self.issue31_private_outbox.remove(&pending_ref);
2215                for gift_wrap in pending.gift_wraps {
2216                    self.issue31_relay_acknowledgements
2217                        .remove(&gift_wrap.id.to_hex());
2218                }
2219            }
2220        }
2221        Ok(())
2222    }
2223
2224    fn record_relay_acknowledgements(
2225        &mut self,
2226        event_id: &str,
2227    ) -> Result<(), SarahConversationError> {
2228        if !self.issue31_relay_acknowledgements.contains_key(event_id)
2229            && self.issue31_relay_acknowledgements.len() >= MAX_RELAY_ACKNOWLEDGEMENTS
2230        {
2231            return Err(SarahConversationError::Internal(
2232                "durable relay acknowledgement bound is exhausted".into(),
2233            ));
2234        }
2235        self.issue31_relay_acknowledgements.insert(
2236            event_id.to_string(),
2237            self.relay.acknowledged_relays(event_id),
2238        );
2239        Ok(())
2240    }
2241
2242    fn persist_issue31_host_state(&self) -> Result<(), SarahConversationError> {
2243        if self.issue31_state_path.is_none() {
2244            return Ok(());
2245        }
2246        let controller = self.issue31_host.as_ref().ok_or_else(|| {
2247            SarahConversationError::Internal(
2248                "Issue 31 controller was absent during durable commit".into(),
2249            )
2250        })?;
2251        self.persist_issue31_host_state_with_controller(controller)
2252    }
2253
2254    fn persist_issue31_host_state_with_controller(
2255        &self,
2256        controller: &Issue31HostController,
2257    ) -> Result<(), SarahConversationError> {
2258        #[cfg(test)]
2259        if let Some(remaining) = self.issue31_fail_commit_after.get() {
2260            if remaining == 0 {
2261                self.issue31_fail_commit_after.set(None);
2262                return Err(SarahConversationError::Internal(
2263                    "injected Issue 31 durable commit failure".into(),
2264                ));
2265            }
2266            self.issue31_fail_commit_after
2267                .set(Some(remaining.saturating_sub(1)));
2268        }
2269        let Some(path) = &self.issue31_state_path else {
2270            return Ok(());
2271        };
2272        let discovery_event_json = self
2273            .issue31_discovery_outbox
2274            .as_ref()
2275            .map(|event| {
2276                event
2277                    .try_as_json()
2278                    .map_err(|error| SarahConversationError::Internal(error.to_string()))
2279            })
2280            .transpose()?;
2281        let private_outbox = self
2282            .issue31_private_outbox
2283            .iter()
2284            .map(|(outbox_ref, pending)| {
2285                let gift_wrap_event_json = pending
2286                    .gift_wraps
2287                    .iter()
2288                    .map(|event| {
2289                        event
2290                            .try_as_json()
2291                            .map_err(|error| SarahConversationError::Internal(error.to_string()))
2292                    })
2293                    .collect::<Result<Vec<_>, _>>()?;
2294                Ok((
2295                    outbox_ref.clone(),
2296                    DurableIssue31PrivatePublish {
2297                        rumor_event_id: pending.rumor_event_id.clone(),
2298                        gift_wrap_event_json,
2299                    },
2300                ))
2301            })
2302            .collect::<Result<BTreeMap<_, _>, SarahConversationError>>()?;
2303        write_issue31_host_state(
2304            path,
2305            &DurableIssue31HostState {
2306                schema: ISSUE31_DURABLE_STATE_SCHEMA.into(),
2307                controller: controller.clone(),
2308                discovery_generation: self.issue31_discovery_generation,
2309                discovery_expires_at: self.issue31_discovery_expires_at,
2310                discovery_event_json,
2311                private_outbox,
2312                relay_acknowledgements: self.issue31_relay_acknowledgements.clone(),
2313                control_cursor: self.issue31_control_cursor.clone(),
2314                projection_cursor: self.issue31_projection_cursor.clone(),
2315                quarantined_events: self.issue31_quarantined_events.clone(),
2316                host_adjunct_emissions: self.issue31_host_adjunct_emissions.clone(),
2317                provider_handoffs: self.issue31_provider_handoffs.clone(),
2318                command_results: self.command_results.clone(),
2319                active_turn_ref: self.active_turn_ref.clone(),
2320                run_state: self.run_state.clone(),
2321                message_seq: self.message_seq,
2322            },
2323        )
2324    }
2325
2326    /// Answer one admitted command-v1 intent (omega#91).
2327    ///
2328    /// The controller has already checked the host binding, the grant, the
2329    /// generation, the lifetime, and the scope, so reaching here means the
2330    /// device is entitled to this action.
2331    ///
2332    /// Only `action.omega.provider_handoff` is answered. The remaining v1
2333    /// actions — Full Auto control and community action — still have no
2334    /// generation-fenced controller behind them, and returning `unavailable`
2335    /// for them is the honest answer rather than a silent success.
2336    fn execute_issue31_action(
2337        &mut self,
2338        action_ref: &str,
2339        arguments_ref: &str,
2340        idempotency_ref: &str,
2341    ) -> Issue31CommandExecution {
2342        if action_ref != ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF {
2343            return Issue31CommandExecution {
2344                status: Issue31CommandStatus::Unavailable,
2345                outcome_ref: "outcome.omega.unavailable".into(),
2346                reason_ref: Some("reason.omega.controller_not_bound".into()),
2347            };
2348        }
2349        // One reading of the clock for this command. The stamp on the record
2350        // and the deadline bounding it come from the same instant, and the
2351        // device supplies neither: `argumentsRef` names a provider and nothing
2352        // else, so there is no wire field a device could put a time in.
2353        let now_ms = unix_now().saturating_mul(1_000);
2354        match self
2355            .issue31_provider_handoffs
2356            .open(arguments_ref, idempotency_ref, now_ms)
2357        {
2358            Ok(record) => Issue31CommandExecution {
2359                status: Issue31CommandStatus::Completed,
2360                // The command was "open a provider connection handoff", and
2361                // this is the record that opening produced — which is also how
2362                // the device finds the row to watch. It is deliberately NOT a
2363                // statement that the handoff completed: the handoff carries its
2364                // own state and its own host-owned outcome, and at this moment
2365                // that state is `requested`.
2366                outcome_ref: record.handoff_ref,
2367                reason_ref: None,
2368            },
2369            Err(error) => Issue31CommandExecution {
2370                status: match error {
2371                    crate::issue31_provider_handoff::Issue31ProviderHandoffError::BoundExhausted
2372                    | crate::issue31_provider_handoff::Issue31ProviderHandoffError::Unprojectable(
2373                        _,
2374                    ) => Issue31CommandStatus::Unavailable,
2375                    _ => Issue31CommandStatus::Refused,
2376                },
2377                outcome_ref: match error {
2378                    crate::issue31_provider_handoff::Issue31ProviderHandoffError::BoundExhausted
2379                    | crate::issue31_provider_handoff::Issue31ProviderHandoffError::Unprojectable(
2380                        _,
2381                    ) => "outcome.omega.unavailable".into(),
2382                    _ => "outcome.omega.refused".into(),
2383                },
2384                reason_ref: Some(error.reason_ref().into()),
2385            },
2386        }
2387    }
2388
2389    /// Install how this host reads its own provider roster (omega#91).
2390    ///
2391    /// Without one, no handoff ever binds: the host would be deciding against a
2392    /// roster nobody read. Open handoffs still reach their deadline, so an
2393    /// unwired host produces `expired` rather than a request that hangs.
2394    pub fn set_issue31_provider_roster_source(&mut self, source: Issue31ProviderRosterSource) {
2395        self.issue31_provider_roster_source = Some(source);
2396    }
2397
2398    /// The host's provider connection handoffs, exactly as persisted.
2399    pub fn issue31_provider_handoff_refs(&self) -> Vec<String> {
2400        self.issue31_provider_handoffs
2401            .records()
2402            .map(|record| record.handoff_ref.clone())
2403            .collect()
2404    }
2405
2406    /// The contract rows this host would publish right now.
2407    pub fn issue31_projected_provider_handoffs(&self, generated_at_ms: u64) -> Vec<Value> {
2408        self.issue31_provider_handoffs
2409            .projected(generated_at_ms)
2410            .rows
2411    }
2412
2413    /// Move every open handoff on by at most one observation of the roster.
2414    ///
2415    /// Run once per host pump pass, before the omega#47 documents are built, so
2416    /// what the phone reads is this pass's state rather than the previous
2417    /// pass's.
2418    fn advance_issue31_provider_handoffs(&mut self, now: u64) {
2419        let roster = self
2420            .issue31_provider_roster_source
2421            .as_ref()
2422            .and_then(|source| source());
2423        self.issue31_provider_handoffs
2424            .advance(roster.as_deref(), now.saturating_mul(1_000));
2425    }
2426
2427    fn execute_issue31_action_v2(
2428        &mut self,
2429        arguments: &Issue31CommandArguments,
2430        idempotency_ref: &str,
2431        grant_ref: &str,
2432        device_public_key_hex: &str,
2433        expected_generation: u64,
2434    ) -> Issue31CommandExecutionV2 {
2435        let handling_suffix = &format!("{:x}", Sha256::digest(idempotency_ref.as_bytes()))[..24];
2436        let accepted = |source_event_id| Issue31CommandExecutionV2 {
2437            status: Issue31CommandHandlingStatus::Accepted,
2438            handling_ref: format!("handling.omega.{handling_suffix}"),
2439            reason_ref: None,
2440            source_event_id,
2441        };
2442        let unavailable = |reason: &str| Issue31CommandExecutionV2 {
2443            status: Issue31CommandHandlingStatus::Unavailable,
2444            handling_ref: format!("handling.omega.{handling_suffix}"),
2445            reason_ref: Some(reason.into()),
2446            source_event_id: None,
2447        };
2448        let failed = |reason: &str| Issue31CommandExecutionV2 {
2449            status: Issue31CommandHandlingStatus::Failed,
2450            handling_ref: format!("handling.omega.{handling_suffix}"),
2451            reason_ref: Some(reason.into()),
2452            source_event_id: None,
2453        };
2454        match arguments {
2455            Issue31CommandArguments::SendMessage {
2456                conversation, text, ..
2457            } if conversation == &self.config.conversation_ref() => {
2458                match self.send_message(text, idempotency_ref, expected_generation) {
2459                    Ok(result) => {
2460                        let projection = Issue31OwnerProjectionBody::Message {
2461                            role: Issue31SourceRole::Owner,
2462                            conversation: conversation.clone(),
2463                            text: text.clone(),
2464                            reply_to_event_id: None,
2465                        };
2466                        match self.enqueue_issue31_source_projection(
2467                            &result.event_id,
2468                            projection,
2469                            grant_ref,
2470                            device_public_key_hex,
2471                            expected_generation,
2472                        ) {
2473                            Ok(()) => accepted(Some(result.event_id)),
2474                            // The owner record is already published by this
2475                            // point. A transport failure reading it back is not
2476                            // the same thing as a projection the host cannot
2477                            // build, and reporting it as terminal `failed`
2478                            // would tell the device a message that exists on
2479                            // the relay does not — the same class of mistake as
2480                            // reporting an authentication failure as a
2481                            // discovery one. `unavailable` is retryable, and
2482                            // the periodic source scan projects it either way.
2483                            Err(
2484                                SarahConversationError::Relay(_)
2485                                | SarahConversationError::Identity(_)
2486                                | SarahConversationError::IdentityRequired,
2487                            ) => unavailable("reason.omega.transport_unavailable"),
2488                            Err(_) => failed("reason.omega.projection_failed"),
2489                        }
2490                    }
2491                    Err(
2492                        SarahConversationError::Relay(_)
2493                        | SarahConversationError::Identity(_)
2494                        | SarahConversationError::IdentityRequired,
2495                    ) => unavailable("reason.omega.transport_unavailable"),
2496                    Err(_) => failed("reason.omega.action_failed"),
2497                }
2498            }
2499            Issue31CommandArguments::InterruptTurn {
2500                conversation,
2501                turn_ref,
2502                ..
2503            } if conversation == &self.config.conversation_ref() => {
2504                match self.interrupt_turn(turn_ref, idempotency_ref, expected_generation) {
2505                    Ok(_) => accepted(None),
2506                    Err(
2507                        SarahConversationError::Relay(_)
2508                        | SarahConversationError::Identity(_)
2509                        | SarahConversationError::IdentityRequired,
2510                    ) => unavailable("reason.omega.transport_unavailable"),
2511                    Err(_) => failed("reason.omega.action_failed"),
2512                }
2513            }
2514            Issue31CommandArguments::SendMessage { .. }
2515            | Issue31CommandArguments::InterruptTurn { .. } => Issue31CommandExecutionV2 {
2516                status: Issue31CommandHandlingStatus::Refused,
2517                handling_ref: format!("handling.omega.{handling_suffix}"),
2518                reason_ref: Some("reason.omega.conversation_mismatch".into()),
2519                source_event_id: None,
2520            },
2521            Issue31CommandArguments::ReadStatePatch { .. }
2522            | Issue31CommandArguments::ReminderCreate { .. }
2523            | Issue31CommandArguments::ReminderChange { .. }
2524            | Issue31CommandArguments::ReminderComplete { .. }
2525            | Issue31CommandArguments::ReminderCancel { .. } => {
2526                match self.execute_issue31_owner_state_action(arguments) {
2527                    Ok((event_id, projection)) => match self.enqueue_issue31_source_projection(
2528                        &event_id,
2529                        projection,
2530                        grant_ref,
2531                        device_public_key_hex,
2532                        expected_generation,
2533                    ) {
2534                        Ok(()) => accepted(Some(event_id)),
2535                        Err(_) => failed("reason.omega.projection_failed"),
2536                    },
2537                    Err(
2538                        SarahConversationError::Relay(_)
2539                        | SarahConversationError::Identity(_)
2540                        | SarahConversationError::IdentityRequired,
2541                    ) => unavailable("reason.omega.transport_unavailable"),
2542                    Err(_) => failed("reason.omega.action_failed"),
2543                }
2544            }
2545        }
2546    }
2547
2548    fn execute_issue31_owner_state_action(
2549        &mut self,
2550        arguments: &Issue31CommandArguments,
2551    ) -> Result<(String, Issue31OwnerProjectionBody), SarahConversationError> {
2552        match arguments {
2553            Issue31CommandArguments::ReadStatePatch {
2554                slot_id,
2555                client_id,
2556                context_ref,
2557                read_at,
2558                ..
2559            } => {
2560                if *read_at > u32::MAX as u64 {
2561                    return Err(SarahConversationError::InvalidRequest(
2562                        "read-state timestamp exceeds the NIP-RS bound".into(),
2563                    ));
2564                }
2565                let d_tag = format!("read-state:{slot_id}");
2566                let mut contexts = self.load_issue31_read_state_contexts(&d_tag)?;
2567                contexts
2568                    .entry(context_ref.clone())
2569                    .and_modify(|current| *current = (*current).max(*read_at))
2570                    .or_insert(*read_at);
2571                if contexts.len() > 10_000 {
2572                    return Err(SarahConversationError::InvalidRequest(
2573                        "read-state context bound is exhausted".into(),
2574                    ));
2575                }
2576                let plaintext = serde_json::to_string(&json!({
2577                    "v": 1,
2578                    "client_id": client_id,
2579                    "contexts": contexts,
2580                }))
2581                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2582                let tags = vec![
2583                    Tag::parse(["d", d_tag.as_str()]).map_err(|error| {
2584                        SarahConversationError::InvalidRequest(error.to_string())
2585                    })?,
2586                    Tag::parse(["t", "read-state"]).map_err(|error| {
2587                        SarahConversationError::InvalidRequest(error.to_string())
2588                    })?,
2589                    Tag::parse(["alt", "encrypted read state"]).map_err(|error| {
2590                        SarahConversationError::InvalidRequest(error.to_string())
2591                    })?,
2592                ];
2593                let event_id =
2594                    self.publish_issue31_encrypted_source(SARAH_READ_STATE_KIND, &plaintext, tags)?;
2595                Ok((
2596                    event_id,
2597                    Issue31OwnerProjectionBody::ReadState { d_tag, plaintext },
2598                ))
2599            }
2600            Issue31CommandArguments::ReminderCreate {
2601                reminder_id,
2602                note,
2603                target_event_id,
2604                not_before,
2605                expiration,
2606                ..
2607            }
2608            | Issue31CommandArguments::ReminderChange {
2609                reminder_id,
2610                note,
2611                target_event_id,
2612                not_before,
2613                expiration,
2614                ..
2615            } => {
2616                let plaintext = serde_json::to_string(&json!({
2617                    "status": "pending",
2618                    "note": note,
2619                    "target": target_event_id.as_ref().map(|event_id| json!({ "id": event_id })),
2620                }))
2621                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2622                let mut tags = vec![
2623                    Tag::parse(["d", reminder_id.as_str()]).map_err(|error| {
2624                        SarahConversationError::InvalidRequest(error.to_string())
2625                    })?,
2626                    Tag::parse(["alt", "Encrypted reminder"]).map_err(|error| {
2627                        SarahConversationError::InvalidRequest(error.to_string())
2628                    })?,
2629                    Tag::parse(["not_before", not_before.to_string().as_str()]).map_err(
2630                        |error| SarahConversationError::InvalidRequest(error.to_string()),
2631                    )?,
2632                ];
2633                if let Some(expiration) = expiration {
2634                    tags.push(
2635                        Tag::parse(["expiration", expiration.to_string().as_str()]).map_err(
2636                            |error| SarahConversationError::InvalidRequest(error.to_string()),
2637                        )?,
2638                    );
2639                }
2640                let event_id =
2641                    self.publish_issue31_encrypted_source(SARAH_REMINDER_KIND, &plaintext, tags)?;
2642                Ok((
2643                    event_id,
2644                    Issue31OwnerProjectionBody::Reminder {
2645                        reminder_id: reminder_id.clone(),
2646                        plaintext,
2647                        not_before: Some(*not_before),
2648                        expiration: *expiration,
2649                    },
2650                ))
2651            }
2652            Issue31CommandArguments::ReminderComplete { reminder_id, .. }
2653            | Issue31CommandArguments::ReminderCancel { reminder_id, .. } => {
2654                let status =
2655                    if matches!(arguments, Issue31CommandArguments::ReminderComplete { .. }) {
2656                        "done"
2657                    } else {
2658                        "cancelled"
2659                    };
2660                let plaintext = serde_json::to_string(&json!({ "status": status }))
2661                    .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2662                let tags = vec![
2663                    Tag::parse(["d", reminder_id.as_str()]).map_err(|error| {
2664                        SarahConversationError::InvalidRequest(error.to_string())
2665                    })?,
2666                    Tag::parse(["alt", "Encrypted reminder"]).map_err(|error| {
2667                        SarahConversationError::InvalidRequest(error.to_string())
2668                    })?,
2669                ];
2670                let event_id =
2671                    self.publish_issue31_encrypted_source(SARAH_REMINDER_KIND, &plaintext, tags)?;
2672                Ok((
2673                    event_id,
2674                    Issue31OwnerProjectionBody::Reminder {
2675                        reminder_id: reminder_id.clone(),
2676                        plaintext,
2677                        not_before: None,
2678                        expiration: None,
2679                    },
2680                ))
2681            }
2682            Issue31CommandArguments::SendMessage { .. }
2683            | Issue31CommandArguments::InterruptTurn { .. } => Err(
2684                SarahConversationError::InvalidRequest("not an owner-state action".into()),
2685            ),
2686        }
2687    }
2688
2689    fn load_issue31_read_state_contexts(
2690        &mut self,
2691        d_tag: &str,
2692    ) -> Result<BTreeMap<String, u64>, SarahConversationError> {
2693        let conversation_ref = self.config.conversation_ref();
2694        let mut cursor = None;
2695        let mut contexts: BTreeMap<String, u64> = BTreeMap::new();
2696        for _ in 0..8 {
2697            let page =
2698                self.query_with_auth(&conversation_ref, cursor.as_deref(), MAX_PAGE_LIMIT)?;
2699            for event in page.events {
2700                if event.kind != SARAH_READ_STATE_KIND
2701                    || event.pubkey != self.config.identity.owner_public_key_hex
2702                    || stored_tag_value(&event.tags, "d").as_deref() != Some(d_tag)
2703                {
2704                    continue;
2705                }
2706                let plaintext = self
2707                    .signer
2708                    .decrypt_record(&event.pubkey, &event.content_summary)?;
2709                let value: Value = serde_json::from_str(&plaintext)
2710                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
2711                let Some(record_contexts) = value.get("contexts").and_then(Value::as_object) else {
2712                    continue;
2713                };
2714                for (context_ref, read_at) in record_contexts {
2715                    let Some(read_at) = read_at.as_u64() else {
2716                        continue;
2717                    };
2718                    if context_ref.len() <= 256 && read_at <= u32::MAX as u64 {
2719                        contexts
2720                            .entry(context_ref.clone())
2721                            .and_modify(|current| *current = (*current).max(read_at))
2722                            .or_insert(read_at);
2723                    }
2724                }
2725            }
2726            let Some(next_cursor) = page.next_cursor else {
2727                break;
2728            };
2729            cursor = Some(next_cursor);
2730        }
2731        Ok(contexts)
2732    }
2733
2734    fn publish_issue31_encrypted_source(
2735        &mut self,
2736        kind: u16,
2737        plaintext: &str,
2738        tags: Vec<Tag>,
2739    ) -> Result<String, SarahConversationError> {
2740        let event = self
2741            .signer
2742            .sign_encrypted_self_record(kind, plaintext, tags)?;
2743        let event_id = event.id.to_hex();
2744        self.publish_with_auth(&event)?;
2745        Ok(event_id)
2746    }
2747
2748    fn enqueue_issue31_source_projection(
2749        &mut self,
2750        source_event_id: &str,
2751        projection: Issue31OwnerProjectionBody,
2752        grant_ref: &str,
2753        device_public_key_hex: &str,
2754        expected_generation: u64,
2755    ) -> Result<(), SarahConversationError> {
2756        let source = self.load_issue31_source_event(source_event_id)?;
2757        if source.pubkey != self.config.identity.owner_public_key_hex {
2758            return Err(SarahConversationError::InvalidRequest(
2759                "Issue 31 projection source is not owner-authored".into(),
2760            ));
2761        }
2762        let emission = emit_issue31_owner_projection(Issue31OwnerProjectionInput {
2763            host_ref: "omega.host.local",
2764            host_public_key_hex: &self.config.identity.owner_public_key_hex,
2765            device_public_key_hex,
2766            sarah_public_key_hex: &self.config.identity.sarah_public_key_hex,
2767            grant_ref,
2768            expected_generation,
2769            source_event_id: &source.event_id,
2770            source_author_public_key_hex: &source.pubkey,
2771            source_kind: source.kind,
2772            source_created_at: source.created_at,
2773            projected_at: unix_now().max(source.created_at),
2774            projection,
2775        })
2776        .map_err(issue31_error)?;
2777        self.enqueue_issue31_private_content(
2778            crate::ISSUE31_OWNER_PROJECTION_SCHEMA,
2779            &emission.content,
2780            device_public_key_hex,
2781        )?;
2782        Ok(())
2783    }
2784
2785    fn load_issue31_source_event(
2786        &mut self,
2787        source_event_id: &str,
2788    ) -> Result<StoredConversationEvent, SarahConversationError> {
2789        let conversation_ref = self.config.conversation_ref();
2790        let mut cursor = self.issue31_projection_cursor.clone();
2791        for _ in 0..8 {
2792            let page =
2793                self.query_with_auth(&conversation_ref, cursor.as_deref(), MAX_PAGE_LIMIT)?;
2794            if let Some(source) = page
2795                .events
2796                .iter()
2797                .find(|event| event.event_id == source_event_id)
2798            {
2799                return Ok(source.clone());
2800            }
2801            let Some(next_cursor) = page.next_cursor else {
2802                break;
2803            };
2804            cursor = Some(next_cursor);
2805        }
2806        Err(SarahConversationError::Relay(
2807            "confirmed Issue 31 source event was not observable after publish".into(),
2808        ))
2809    }
2810
2811    fn project_issue31_sources(
2812        &mut self,
2813        controller: &mut Issue31HostController,
2814        now: u64,
2815    ) -> Result<(), SarahConversationError> {
2816        let grants = controller.active_grants(now).map_err(issue31_error)?;
2817        if grants.is_empty() {
2818            return Ok(());
2819        }
2820        let conversation_ref = self.config.conversation_ref();
2821        let mut cursor = self.issue31_projection_cursor.clone();
2822        let mut last_scanned_cursor = None;
2823        let mut scan_bound_reached = true;
2824        for _ in 0..8 {
2825            let page =
2826                self.query_with_auth(&conversation_ref, cursor.as_deref(), MAX_PAGE_LIMIT)?;
2827            if let Some(page_cursor) = page.events.last().map(stored_event_cursor) {
2828                last_scanned_cursor = Some(page_cursor);
2829            }
2830            for source in page.events {
2831                if !matches!(
2832                    source.kind,
2833                    crate::ISSUE31_PRIVATE_RUMOR_KIND
2834                        | SARAH_TURN_RECORD_KIND
2835                        | SARAH_AUTHORITY_RECEIPT_KIND
2836                        | SARAH_ENGRAM_KIND
2837                        | SARAH_READ_STATE_KIND
2838                        | SARAH_REMINDER_KIND
2839                ) {
2840                    continue;
2841                }
2842                if source.kind == crate::ISSUE31_PRIVATE_RUMOR_KIND
2843                    && source.record_kind != "message"
2844                {
2845                    continue;
2846                }
2847                let projection = match self.issue31_projection_body(&source) {
2848                    Ok(Some(projection)) => projection,
2849                    Ok(None) => continue,
2850                    Err(_) => {
2851                        self.quarantine_issue31_event(
2852                            &source.event_id,
2853                            ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON,
2854                            controller,
2855                        )?;
2856                        continue;
2857                    }
2858                };
2859                let mut emissions = Vec::new();
2860                let mut refused_by_own_decoder = false;
2861                for grant in &grants {
2862                    if controller.source_was_projected(
2863                        &grant.grant_ref,
2864                        grant.generation,
2865                        &source.event_id,
2866                    ) {
2867                        continue;
2868                    }
2869                    match emit_issue31_owner_projection(Issue31OwnerProjectionInput {
2870                        host_ref: &grant.host_ref,
2871                        host_public_key_hex: &grant.host_public_key_hex,
2872                        device_public_key_hex: &grant.device_public_key_hex,
2873                        sarah_public_key_hex: &grant.sarah_public_key_hex,
2874                        grant_ref: &grant.grant_ref,
2875                        expected_generation: grant.generation,
2876                        source_event_id: &source.event_id,
2877                        source_author_public_key_hex: &source.pubkey,
2878                        source_kind: source.kind,
2879                        source_created_at: source.created_at,
2880                        projected_at: now.max(source.created_at),
2881                        projection: projection.clone(),
2882                    }) {
2883                        Ok(emission) => emissions.push((grant, emission)),
2884                        // A source event Sarah or the owner signed can still
2885                        // carry a reference or a body the device reader
2886                        // refuses. Quarantining that one event keeps the
2887                        // remaining sources projecting, rather than letting a
2888                        // single malformed record stop every device.
2889                        Err(_) => {
2890                            refused_by_own_decoder = true;
2891                            break;
2892                        }
2893                    }
2894                }
2895                if refused_by_own_decoder {
2896                    self.quarantine_issue31_event(
2897                        &source.event_id,
2898                        ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON,
2899                        controller,
2900                    )?;
2901                    continue;
2902                }
2903                for (grant, emission) in emissions {
2904                    self.enqueue_issue31_private_content(
2905                        crate::ISSUE31_OWNER_PROJECTION_SCHEMA,
2906                        &emission.content,
2907                        &grant.device_public_key_hex,
2908                    )?;
2909                    controller
2910                        .record_source_projection(
2911                            grant.grant_ref.clone(),
2912                            grant.generation,
2913                            source.event_id.clone(),
2914                        )
2915                        .map_err(issue31_error)?;
2916                }
2917            }
2918            let Some(next_cursor) = page.next_cursor else {
2919                scan_bound_reached = false;
2920                break;
2921            };
2922            cursor = Some(next_cursor);
2923        }
2924        if scan_bound_reached {
2925            // The scan ran out of pages before it ran out of conversation. The
2926            // host does not know how many sources it never looked at, so it
2927            // must say so rather than let the device read a short list as a
2928            // complete one.
2929            self.last_gap_state = strongest_gap_state(self.last_gap_state, GapState::Possible);
2930        }
2931        if let Some(last_scanned_cursor) = last_scanned_cursor {
2932            self.issue31_projection_cursor = Some(last_scanned_cursor);
2933        }
2934        self.emit_issue31_withheld_sources(&grants, scan_bound_reached, now)?;
2935        Ok(())
2936    }
2937
2938    /// Install the live Full Auto reading this host publishes to devices.
2939    ///
2940    /// Without one the host publishes no omega#47 records at all, and a paired
2941    /// phone reads `no_host_projection` — which is the honest answer for a host
2942    /// that is not observing its own Full Auto state, and is exactly what the
2943    /// phone showed before this existed. The difference is that the silence is
2944    /// now a stated condition rather than a dropped record.
2945    pub fn set_issue31_host_projection_source(&mut self, source: Issue31HostProjectionSource) {
2946        self.issue31_host_projection_source = Some(source);
2947    }
2948
2949    /// Bind the issue-31 host controller this client pumps.
2950    ///
2951    /// `new_production` builds one from custody. A headless harness cannot
2952    /// reach custody and still has to drive the shipped pump, so the binding is
2953    /// a real operation rather than a field only this module can reach.
2954    pub fn attach_issue31_host_controller(&mut self, controller: Issue31HostController) {
2955        self.issue31_host = Some(controller);
2956    }
2957
2958    /// The `grant_ref:generation` keys this host has published omega#47
2959    /// documents to. A key is recorded only after both records were committed
2960    /// to the durable outbox.
2961    pub fn issue31_published_host_adjunct_grants(&self) -> Vec<String> {
2962        self.issue31_host_adjunct_emissions.keys().cloned().collect()
2963    }
2964
2965    /// Owner-private records still waiting for a relay acknowledgement.
2966    ///
2967    /// An empty list after a pump pass means every relay the host is
2968    /// configured for stored every record; a non-empty one is an exact,
2969    /// resumable backlog rather than a lost publish.
2970    pub fn issue31_pending_private_publish_refs(&self) -> Vec<String> {
2971        self.issue31_private_outbox.keys().cloned().collect()
2972    }
2973
2974    /// Address one omega#47 document to one device.
2975    ///
2976    /// The pump — not the reading — states the binding, because the reading
2977    /// knows the host's runs and knows nothing about who may read them. A
2978    /// document that arrives already claiming a delivery binding is refused
2979    /// rather than overwritten: whoever wrote it was making a claim about a
2980    /// device it has no standing to make.
2981    fn address_issue31_adjunct(
2982        document: &Value,
2983        schema: &str,
2984        record_type: &str,
2985        grant: &Issue31GrantState,
2986    ) -> Result<Value, SarahConversationError> {
2987        let object = document.as_object().ok_or_else(|| {
2988            SarahConversationError::Internal("Issue 31 adjunct is not a record".into())
2989        })?;
2990        if object.get("schema").and_then(Value::as_str) != Some(schema) {
2991            return Err(SarahConversationError::Internal(
2992                "Issue 31 adjunct does not carry its own schema".into(),
2993            ));
2994        }
2995        // The seal proves who signed; it cannot prove which host the body
2996        // describes. The grant is the only statement that relates this host key
2997        // to a host reference, so the body must agree with it or the device
2998        // would bind another machine's state to this pairing.
2999        if object.get("hostRef").and_then(Value::as_str) != Some(grant.host_ref.as_str()) {
3000            return Err(SarahConversationError::Internal(
3001                "Issue 31 adjunct describes a host this grant does not name".into(),
3002            ));
3003        }
3004        if ISSUE31_ADJUNCT_DELIVERY_KEYS
3005            .iter()
3006            .any(|key| object.contains_key(*key))
3007        {
3008            return Err(SarahConversationError::Internal(
3009                "Issue 31 adjunct arrived already claiming a delivery binding".into(),
3010            ));
3011        }
3012        let mut addressed = object.clone();
3013        addressed.insert("recordType".into(), json!(record_type));
3014        addressed.insert(
3015            "hostPublicKeyHex".into(),
3016            json!(grant.host_public_key_hex.clone()),
3017        );
3018        addressed.insert(
3019            "devicePublicKeyHex".into(),
3020            json!(grant.device_public_key_hex.clone()),
3021        );
3022        addressed.insert("grantRef".into(), json!(grant.grant_ref.clone()));
3023        addressed.insert("expectedGeneration".into(), json!(grant.generation));
3024        Ok(Value::Object(addressed))
3025    }
3026
3027    /// Publish the omega#47 host snapshot and its Full Auto detail to every
3028    /// admitted device (omega#49).
3029    ///
3030    /// The two documents are published together or not at all. The detail is
3031    /// bound to the snapshot that advertised it, and a device that held one
3032    /// without the other would either render a detail nothing vouches for or
3033    /// advertise capabilities it cannot open.
3034    fn publish_issue31_host_adjuncts(
3035        &mut self,
3036        controller: &Issue31HostController,
3037        now: u64,
3038    ) -> Result<(), SarahConversationError> {
3039        let Some(source) = self.issue31_host_projection_source.clone() else {
3040            return Ok(());
3041        };
3042        let grants = controller.active_grants(now).map_err(issue31_error)?;
3043        let observed_at_ms = now.saturating_mul(1_000);
3044        // omega#91. A handoff the host holds and can never state — one whose
3045        // request time was never measured — makes the owner's view short, and
3046        // that has to be visible rather than read as "no handoff in flight".
3047        if !self.issue31_provider_handoffs.unstateable_refs().is_empty() {
3048            self.last_gap_state = strongest_gap_state(self.last_gap_state, GapState::Possible);
3049        }
3050        for grant in grants {
3051            let documents = match source(&Issue31HostProjectionRequest {
3052                host_ref: &grant.host_ref,
3053                host_public_key_hex: &grant.host_public_key_hex,
3054                device_public_key_hex: &grant.device_public_key_hex,
3055                grant_ref: &grant.grant_ref,
3056                expected_generation: grant.generation,
3057                observed_at_ms,
3058                handoffs: &self.issue31_provider_handoffs,
3059            }) {
3060                Ok(Some(documents)) => documents,
3061                // Nothing observed is said by saying nothing. Publishing an
3062                // empty projection here would claim the host had looked and
3063                // found no runs, which is a different fact.
3064                Ok(None) => continue,
3065                Err(_) => {
3066                    // The host could not read its own Full Auto state. The
3067                    // owner's view may therefore be short, and that has to be
3068                    // visible rather than inferred from an absent record.
3069                    self.last_gap_state =
3070                        strongest_gap_state(self.last_gap_state, GapState::Possible);
3071                    continue;
3072                }
3073            };
3074            let host = Self::address_issue31_adjunct(
3075                &documents.host,
3076                ISSUE31_HOST_ADJUNCT_SCHEMA,
3077                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
3078                &grant,
3079            )?;
3080            let detail = Self::address_issue31_adjunct(
3081                &documents.detail,
3082                ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
3083                ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE,
3084                &grant,
3085            )?;
3086            // "Beside" is the contract's word for the relation between the two
3087            // documents. A detail carrying a different snapshot reference is
3088            // one the device would refuse as `snapshot_mismatch`, so sending it
3089            // would publish a refusal rather than a projection.
3090            if host.get("snapshotRef") != detail.get("snapshotRef") {
3091                return Err(SarahConversationError::Internal(
3092                    "Issue 31 Full Auto detail is not bound to the snapshot beside it".into(),
3093                ));
3094            }
3095            let host_content = serde_json::to_string(&host)
3096                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3097            let detail_content = serde_json::to_string(&detail)
3098                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3099            let key = format!("{}:{}", grant.grant_ref, grant.generation);
3100            let digest = format!(
3101                "{:x}",
3102                Sha256::digest(
3103                    [host_content.as_bytes(), detail_content.as_bytes()].concat()
3104                )
3105            );
3106            if self.issue31_host_adjunct_emissions.get(&key) == Some(&digest) {
3107                continue;
3108            }
3109            let enqueued_host = self.enqueue_issue31_private_content(
3110                ISSUE31_HOST_ADJUNCT_SCHEMA,
3111                &host_content,
3112                &grant.device_public_key_hex,
3113            )?;
3114            let enqueued_detail = match self.enqueue_issue31_private_content(
3115                ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
3116                &detail_content,
3117                &grant.device_public_key_hex,
3118            ) {
3119                Ok(enqueued) => enqueued,
3120                Err(error) => {
3121                    self.rollback_issue31_enqueue(&enqueued_host);
3122                    return Err(error);
3123                }
3124            };
3125            let previous = self
3126                .issue31_host_adjunct_emissions
3127                .insert(key.clone(), digest);
3128            // The bookkeeping is committed with the outbox, not after it: a
3129            // crash between the two would either resend forever or, worse,
3130            // record a publication that never happened.
3131            if let Err(error) = self.persist_issue31_host_state_with_controller(controller) {
3132                match previous {
3133                    Some(previous) => {
3134                        self.issue31_host_adjunct_emissions.insert(key, previous);
3135                    }
3136                    None => {
3137                        self.issue31_host_adjunct_emissions.remove(&key);
3138                    }
3139                }
3140                self.rollback_issue31_enqueue(&enqueued_detail);
3141                self.rollback_issue31_enqueue(&enqueued_host);
3142                return Err(error);
3143            }
3144        }
3145        Ok(())
3146    }
3147
3148    /// Tell every admitted device how complete its own projection is.
3149    ///
3150    /// This is the device-visible half of the two host-local surfaces that
3151    /// silently shorten the owner's view: the quarantine, whose count only ever
3152    /// reached `BootstrapResult`, and the bounded projection scan, which only
3153    /// ever moved `last_gap_state`. A complete pass publishes a record too —
3154    /// silence has to mean "unknown", or the absence of a signal reads as
3155    /// completeness and this whole mechanism buys nothing.
3156    fn emit_issue31_withheld_sources(
3157        &mut self,
3158        grants: &[Issue31GrantState],
3159        scan_bound_reached: bool,
3160        now: u64,
3161    ) -> Result<(), SarahConversationError> {
3162        let quarantined = u32::try_from(
3163            self.issue31_quarantined_events
3164                .values()
3165                .filter(|reason| reason.as_str() == ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON)
3166                .count(),
3167        )
3168        .unwrap_or(u32::MAX);
3169        let mut withheld = Vec::new();
3170        if quarantined > 0 {
3171            withheld.push(Issue31WithheldSourceCount {
3172                cause: Issue31WithheldCause::Quarantined,
3173                count: quarantined,
3174                exact: true,
3175                reason_ref: ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON.to_string(),
3176            });
3177        }
3178        if scan_bound_reached {
3179            withheld.push(Issue31WithheldSourceCount {
3180                cause: Issue31WithheldCause::ScanBound,
3181                count: 1,
3182                exact: false,
3183                reason_ref: ISSUE31_PROJECTION_SCAN_BOUND_REASON.to_string(),
3184            });
3185        }
3186        for grant in grants {
3187            let emission = emit_issue31_withheld_sources(Issue31WithheldSourcesInput {
3188                host_ref: &grant.host_ref,
3189                host_public_key_hex: &grant.host_public_key_hex,
3190                device_public_key_hex: &grant.device_public_key_hex,
3191                grant_ref: &grant.grant_ref,
3192                expected_generation: grant.generation,
3193                observed_at: now,
3194                withheld: withheld.clone(),
3195            })
3196            .map_err(issue31_error)?;
3197            let key = format!("{}:{}", grant.grant_ref, grant.generation);
3198            let substance = emission.record.substance();
3199            if self.issue31_withheld_emissions.get(&key) == Some(&substance) {
3200                continue;
3201            }
3202            self.enqueue_issue31_private_content(
3203                crate::ISSUE31_WITHHELD_SOURCES_SCHEMA,
3204                &emission.content,
3205                &grant.device_public_key_hex,
3206            )?;
3207            self.issue31_withheld_emissions.insert(key, substance);
3208        }
3209        Ok(())
3210    }
3211
3212    fn issue31_projection_body(
3213        &self,
3214        source: &StoredConversationEvent,
3215    ) -> Result<Option<Issue31OwnerProjectionBody>, SarahConversationError> {
3216        let owner_key = &self.config.identity.owner_public_key_hex;
3217        let sarah_key = &self.config.identity.sarah_public_key_hex;
3218        match source.kind {
3219            crate::ISSUE31_PRIVATE_RUMOR_KIND
3220                if source.record_kind == "message"
3221                    && (source.pubkey == *owner_key || source.pubkey == *sarah_key) =>
3222            {
3223                let role = if source.pubkey == *owner_key {
3224                    Issue31SourceRole::Owner
3225                } else {
3226                    Issue31SourceRole::Sarah
3227                };
3228                Ok(Some(Issue31OwnerProjectionBody::Message {
3229                    role,
3230                    conversation: self.config.conversation_ref(),
3231                    text: source.content_summary.clone(),
3232                    reply_to_event_id: stored_tag_value(&source.tags, "e"),
3233                }))
3234            }
3235            SARAH_TURN_RECORD_KIND if source.pubkey == *sarah_key => {
3236                let plaintext = self
3237                    .signer
3238                    .decrypt_record(&source.pubkey, &source.content_summary)?;
3239                let payload = serde_json::from_str::<Value>(&plaintext)
3240                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
3241                Ok(Some(Issue31OwnerProjectionBody::Turn { payload }))
3242            }
3243            SARAH_AUTHORITY_RECEIPT_KIND if source.pubkey == *sarah_key => {
3244                let plaintext = self
3245                    .signer
3246                    .decrypt_record(&source.pubkey, &source.content_summary)?;
3247                let value = serde_json::from_str::<Value>(&plaintext)
3248                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
3249                let state = match value.get("decision").and_then(Value::as_str) {
3250                    Some("allow") => "allowed",
3251                    Some("refuse") => "refused",
3252                    _ => {
3253                        return Err(SarahConversationError::InvalidRequest(
3254                            "authority receipt has an invalid decision".into(),
3255                        ));
3256                    }
3257                };
3258                let turn_ref = stored_tag_value(&source.tags, "turn").ok_or_else(|| {
3259                    SarahConversationError::InvalidRequest(
3260                        "authority receipt omitted its turn tag".into(),
3261                    )
3262                })?;
3263                let suffix = &source.event_id[..24];
3264                let reason_ref = value
3265                    .get("reservedCategory")
3266                    .and_then(Value::as_str)
3267                    .filter(|category| {
3268                        category
3269                            .bytes()
3270                            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
3271                    })
3272                    .map(|category| format!("reason.openagents.{category}"));
3273                Ok(Some(Issue31OwnerProjectionBody::AuthorityReceipt {
3274                    receipt_ref: format!("receipt.issue31.{suffix}"),
3275                    turn_ref,
3276                    authority_decision: Issue31AuthorityDecisionProjection {
3277                        state: state.into(),
3278                        decision_ref: format!("decision.issue31.{suffix}"),
3279                        reason_ref,
3280                    },
3281                    target_outcome: Issue31TargetOutcomeProjection {
3282                        state: "pending".into(),
3283                        outcome_ref: None,
3284                        reason_ref: None,
3285                    },
3286                }))
3287            }
3288            SARAH_ENGRAM_KIND if source.pubkey == *sarah_key => {
3289                let plaintext = self
3290                    .signer
3291                    .decrypt_record(&source.pubkey, &source.content_summary)?;
3292                let d_tag = stored_tag_value(&source.tags, "d").ok_or_else(|| {
3293                    SarahConversationError::InvalidRequest("engram omitted its d tag".into())
3294                })?;
3295                Ok(Some(Issue31OwnerProjectionBody::Engram {
3296                    d_tag,
3297                    plaintext,
3298                }))
3299            }
3300            SARAH_READ_STATE_KIND if source.pubkey == *owner_key => {
3301                let plaintext = self
3302                    .signer
3303                    .decrypt_record(&source.pubkey, &source.content_summary)?;
3304                let d_tag = stored_tag_value(&source.tags, "d").ok_or_else(|| {
3305                    SarahConversationError::InvalidRequest("read state omitted its d tag".into())
3306                })?;
3307                Ok(Some(Issue31OwnerProjectionBody::ReadState {
3308                    d_tag,
3309                    plaintext,
3310                }))
3311            }
3312            SARAH_REMINDER_KIND if source.pubkey == *owner_key => {
3313                let plaintext = self
3314                    .signer
3315                    .decrypt_record(&source.pubkey, &source.content_summary)?;
3316                let reminder_id = stored_tag_value(&source.tags, "d").ok_or_else(|| {
3317                    SarahConversationError::InvalidRequest("reminder omitted its d tag".into())
3318                })?;
3319                let not_before = stored_tag_value(&source.tags, "not_before")
3320                    .map(|value| value.parse::<u64>())
3321                    .transpose()
3322                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
3323                let expiration = stored_tag_value(&source.tags, "expiration")
3324                    .map(|value| value.parse::<u64>())
3325                    .transpose()
3326                    .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
3327                Ok(Some(Issue31OwnerProjectionBody::Reminder {
3328                    reminder_id,
3329                    plaintext,
3330                    not_before,
3331                    expiration,
3332                }))
3333            }
3334            _ => Ok(None),
3335        }
3336    }
3337
3338    pub fn room_snapshot(
3339        &mut self,
3340        cursor: Option<&str>,
3341        limit: Option<usize>,
3342    ) -> Result<RoomSnapshotResult, SarahConversationError> {
3343        self.room_snapshot_with_record_cursor(cursor, limit, cursor, limit, cursor, limit)
3344    }
3345
3346    pub fn room_snapshot_with_cursors(
3347        &mut self,
3348        transcript_cursor: Option<&str>,
3349        transcript_limit: Option<usize>,
3350        activity_cursor: Option<&str>,
3351        activity_limit: Option<usize>,
3352    ) -> Result<RoomSnapshotResult, SarahConversationError> {
3353        self.room_snapshot_with_record_cursor(
3354            transcript_cursor,
3355            transcript_limit,
3356            activity_cursor,
3357            activity_limit,
3358            None,
3359            None,
3360        )
3361    }
3362
3363    pub fn room_snapshot_with_record_cursor(
3364        &mut self,
3365        transcript_cursor: Option<&str>,
3366        transcript_limit: Option<usize>,
3367        activity_cursor: Option<&str>,
3368        activity_limit: Option<usize>,
3369        nostr_cursor: Option<&str>,
3370        nostr_limit: Option<usize>,
3371    ) -> Result<RoomSnapshotResult, SarahConversationError> {
3372        self.ensure_connected()?;
3373        self.sync_issue31_host()?;
3374        let transcript_limit = transcript_limit
3375            .unwrap_or(DEFAULT_PAGE_LIMIT)
3376            .clamp(1, MAX_PAGE_LIMIT);
3377        let activity_limit = activity_limit
3378            .unwrap_or(DEFAULT_PAGE_LIMIT)
3379            .clamp(1, MAX_PAGE_LIMIT);
3380        let nostr_limit = nostr_limit
3381            .unwrap_or(DEFAULT_PAGE_LIMIT)
3382            .clamp(1, MAX_PAGE_LIMIT);
3383        let conversation_ref = self.config.conversation_ref();
3384        let transcript_page =
3385            self.query_with_auth(&conversation_ref, transcript_cursor, MAX_PAGE_LIMIT)?;
3386        let activity_page =
3387            self.query_with_auth(&conversation_ref, activity_cursor, MAX_PAGE_LIMIT)?;
3388        let nostr_page = self.query_with_auth(&conversation_ref, nostr_cursor, MAX_PAGE_LIMIT)?;
3389        self.last_gap_state = strongest_gap_state(
3390            strongest_gap_state(transcript_page.gap_state, activity_page.gap_state),
3391            nostr_page.gap_state,
3392        );
3393        self.last_confirmed_cursor = transcript_page
3394            .events
3395            .last()
3396            .or_else(|| activity_page.events.last())
3397            .or_else(|| nostr_page.events.last())
3398            .map(stored_event_cursor);
3399        let transcript_matching_count = transcript_page
3400            .events
3401            .iter()
3402            .filter(|event| event.record_kind == "message")
3403            .count();
3404        let transcript_entries = transcript_page
3405            .events
3406            .iter()
3407            .filter(|event| event.record_kind == "message")
3408            .take(transcript_limit)
3409            .map(|event| {
3410                let role = if event.pubkey == self.config.identity.owner_public_key_hex {
3411                    "owner"
3412                } else {
3413                    "sarah"
3414                };
3415                TranscriptEntry {
3416                    event_id: event.event_id.clone(),
3417                    cursor: stored_event_cursor(event),
3418                    role: role.to_string(),
3419                    kind: "text".to_string(),
3420                    text: event.content_summary.clone(),
3421                    created_at: iso_from_unix(event.created_at),
3422                    status: "confirmed".to_string(),
3423                }
3424            })
3425            .collect::<Vec<_>>();
3426        let activity_matching_count = activity_page
3427            .events
3428            .iter()
3429            .filter(|event| event.record_kind == "activity")
3430            .count();
3431        let activity_entries = activity_page
3432            .events
3433            .iter()
3434            .filter(|event| event.record_kind == "activity")
3435            .take(activity_limit)
3436            .map(|event| ActivityEntry {
3437                event_id: event.event_id.clone(),
3438                cursor: stored_event_cursor(event),
3439                entry: tag_value(&event.tags, "entry").unwrap_or_else(|| "unknown".into()),
3440                turn_ref: tag_value(&event.tags, "turn").unwrap_or_else(|| "turn.unknown".into()),
3441                created_at: iso_from_unix(event.created_at),
3442            })
3443            .collect::<Vec<_>>();
3444        let nostr_matching_count = nostr_page
3445            .events
3446            .iter()
3447            .filter(|event| confirmed_nostr_projection_kind(event.kind))
3448            .count();
3449        let nostr_entries = nostr_page
3450            .events
3451            .iter()
3452            .filter(|event| confirmed_nostr_projection_kind(event.kind))
3453            .take(nostr_limit)
3454            .map(|event| NostrRecordRef {
3455                event_id: event.event_id.clone(),
3456                cursor: stored_event_cursor(event),
3457                kind: event.kind,
3458                record_kind: event.record_kind.clone(),
3459                author_fingerprint: public_key_fingerprint(&event.pubkey),
3460                created_at: iso_from_unix(event.created_at),
3461                source: "confirmed_nostr".into(),
3462            })
3463            .collect::<Vec<_>>();
3464        let transcript_page_cursor = transcript_entries
3465            .last()
3466            .map(|entry| entry.cursor.clone())
3467            .unwrap_or_else(|| transcript_cursor.unwrap_or("cursor.start").to_string());
3468        let activity_page_cursor = activity_entries
3469            .last()
3470            .map(|entry| entry.cursor.clone())
3471            .unwrap_or_else(|| activity_cursor.unwrap_or("cursor.start").to_string());
3472        let nostr_page_cursor = nostr_entries
3473            .last()
3474            .map(|entry| entry.cursor.clone())
3475            .unwrap_or_else(|| nostr_cursor.unwrap_or("cursor.start").to_string());
3476        let transcript_next_cursor = stream_next_cursor(
3477            transcript_entries.last().map(|entry| entry.cursor.as_str()),
3478            transcript_page.next_cursor.as_deref(),
3479            transcript_matching_count > transcript_limit,
3480        );
3481        let activity_next_cursor = stream_next_cursor(
3482            activity_entries.last().map(|entry| entry.cursor.as_str()),
3483            activity_page.next_cursor.as_deref(),
3484            activity_matching_count > activity_limit,
3485        );
3486        let nostr_next_cursor = stream_next_cursor(
3487            nostr_entries.last().map(|entry| entry.cursor.as_str()),
3488            nostr_page.next_cursor.as_deref(),
3489            nostr_matching_count > nostr_limit,
3490        );
3491        Ok(RoomSnapshotResult {
3492            conversation_ref,
3493            transcript: TranscriptPage {
3494                entries: transcript_entries,
3495                cursor: transcript_page_cursor,
3496                next_cursor: transcript_next_cursor,
3497                gap_state: transcript_page.gap_state,
3498            },
3499            activity: ActivityPage {
3500                entries: activity_entries,
3501                cursor: activity_page_cursor,
3502                next_cursor: activity_next_cursor,
3503                gap_state: activity_page.gap_state,
3504            },
3505            nostr_records: NostrRecordPage {
3506                entries: nostr_entries,
3507                cursor: nostr_page_cursor,
3508                next_cursor: nostr_next_cursor,
3509                gap_state: nostr_page.gap_state,
3510                source: "confirmed_nostr".into(),
3511            },
3512            run_state: RunStateProjection {
3513                state: self.run_state.clone(),
3514                turn_ref: self.active_turn_ref.clone(),
3515                reason: None,
3516            },
3517            room_state: self.current_room_state(),
3518        })
3519    }
3520
3521    pub fn send_message(
3522        &mut self,
3523        text: &str,
3524        idempotency_ref: &str,
3525        expected_generation: u64,
3526    ) -> Result<SendMessageResult, SarahConversationError> {
3527        self.send_message_with_fingerprint(text, idempotency_ref, expected_generation, None)
3528    }
3529
3530    fn send_message_with_fingerprint(
3531        &mut self,
3532        text: &str,
3533        idempotency_ref: &str,
3534        expected_generation: u64,
3535        command_fingerprint: Option<String>,
3536    ) -> Result<SendMessageResult, SarahConversationError> {
3537        self.ensure_connected()?;
3538        self.ensure_generation(expected_generation)?;
3539        validate_command_ref(idempotency_ref, "idempotencyRef")?;
3540        if command_fingerprint.is_some() {
3541            self.ensure_command_result_capacity(idempotency_ref)?;
3542        }
3543        let text = text.trim();
3544        if text.is_empty() {
3545            return Err(SarahConversationError::InvalidRequest(
3546                "message text must not be empty".into(),
3547            ));
3548        }
3549        if text.len() > 8_000 {
3550            return Err(SarahConversationError::InvalidRequest(
3551                "message text exceeds page budget".into(),
3552            ));
3553        }
3554        // Refuse anything that looks like a raw credential in the outbound path.
3555        if looks_like_secret(text) {
3556            return Err(SarahConversationError::InvalidRequest(
3557                "message must not carry raw credentials".into(),
3558            ));
3559        }
3560
3561        let next_message_seq = self.message_seq.checked_add(1).ok_or_else(|| {
3562            SarahConversationError::InvalidRequest("message sequence is exhausted".into())
3563        })?;
3564        let turn_ref = format!("turn.{next_message_seq}");
3565        let message_ref = format!("msg.{next_message_seq}");
3566
3567        let conversation_ref = self.config.conversation_ref();
3568        let tags = conversation_tags(
3569            &conversation_ref,
3570            &self.config.identity.owner_public_key_hex,
3571            &self.config.identity.sarah_public_key_hex,
3572        )?;
3573        let mut tags = tags;
3574        tags.push(
3575            Tag::parse(["idempotency", idempotency_ref])
3576                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
3577        );
3578        tags.push(
3579            Tag::parse(["generation", &expected_generation.to_string()])
3580                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
3581        );
3582        tags.push(
3583            Tag::parse(["turn", &turn_ref])
3584                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
3585        );
3586        let mut public_event = None;
3587        let mut inserted_outbox_ref = None;
3588        let (event_id, created_at) = if self.relay.requires_private_messages() {
3589            let recipients = private_recipients(
3590                &self.config.identity.owner_public_key_hex,
3591                &self.config.identity.sarah_public_key_hex,
3592            )?;
3593            let outbox_ref = private_outbox_ref("sarah.message", text, &tags, &recipients);
3594            let (rumor_event_id, inserted) =
3595                self.enqueue_private_content(&outbox_ref, text, tags, &recipients)?;
3596            if inserted {
3597                inserted_outbox_ref = Some(outbox_ref);
3598            }
3599            (rumor_event_id, unix_now())
3600        } else {
3601            let event = match &self.signer {
3602                ConversationSigner::Keys(identity) => identity.sign_text_note(text, tags)?,
3603                ConversationSigner::OmegaIdentity(_) => {
3604                    return Err(SarahConversationError::Internal(
3605                        "custodied production signer requires NIP-17 transport".into(),
3606                    ));
3607                }
3608            };
3609            let event_id = event.id.to_hex();
3610            let created_at = event.created_at.as_secs();
3611            public_event = Some(event);
3612            (event_id, created_at)
3613        };
3614        let cursor = format!("{CURSOR_PREFIX}{}", next_message_seq.saturating_sub(1));
3615        let response = SendMessageResult {
3616            accepted: true,
3617            message_ref,
3618            turn_ref: turn_ref.clone(),
3619            event_id: event_id.clone(),
3620            cursor: cursor.clone(),
3621            status: "accepted".to_string(),
3622        };
3623        let response_value = command_fingerprint
3624            .as_ref()
3625            .map(|_| serde_json::to_value(&response))
3626            .transpose()
3627            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3628        let previous_active_turn_ref = self.active_turn_ref.clone();
3629        let previous_run_state = self.run_state.clone();
3630        let previous_message_seq = self.message_seq;
3631        self.message_seq = next_message_seq;
3632        self.active_turn_ref = Some(turn_ref);
3633        self.run_state = "running".to_string();
3634        if let (Some(fingerprint), Some(response_value)) =
3635            (command_fingerprint.as_ref(), response_value.as_ref())
3636        {
3637            self.command_results.insert(
3638                idempotency_ref.to_string(),
3639                (fingerprint.clone(), response_value.clone()),
3640            );
3641        }
3642        if let Err(error) = self.persist_issue31_host_state() {
3643            self.message_seq = previous_message_seq;
3644            self.active_turn_ref = previous_active_turn_ref;
3645            self.run_state = previous_run_state;
3646            if command_fingerprint.is_some() {
3647                self.command_results.remove(idempotency_ref);
3648            }
3649            if let Some(outbox_ref) = inserted_outbox_ref {
3650                self.issue31_private_outbox.remove(&outbox_ref);
3651            }
3652            return Err(error);
3653        }
3654        let publication = if let Some(event) = public_event.as_ref() {
3655            self.publish_with_auth(event)
3656        } else {
3657            self.flush_issue31_outbox()
3658        };
3659        let persistence = self.persist_issue31_host_state();
3660        finish_durable_operation(publication, persistence)?;
3661        let record = TranscriptEntry {
3662            event_id,
3663            cursor,
3664            role: "owner".to_string(),
3665            kind: "text".to_string(),
3666            text: redact_content_summary(text),
3667            created_at: iso_from_unix(created_at),
3668            status: "accepted".to_string(),
3669        };
3670        self.push_room_event(&record);
3671        self.push_room_state_event(&self.current_room_state());
3672        Ok(response)
3673    }
3674
3675    pub fn interrupt_turn(
3676        &mut self,
3677        turn_ref: &str,
3678        idempotency_ref: &str,
3679        expected_generation: u64,
3680    ) -> Result<InterruptTurnResult, SarahConversationError> {
3681        self.interrupt_turn_with_fingerprint(turn_ref, idempotency_ref, expected_generation, None)
3682    }
3683
3684    fn interrupt_turn_with_fingerprint(
3685        &mut self,
3686        turn_ref: &str,
3687        idempotency_ref: &str,
3688        expected_generation: u64,
3689        command_fingerprint: Option<String>,
3690    ) -> Result<InterruptTurnResult, SarahConversationError> {
3691        self.ensure_connected()?;
3692        self.ensure_generation(expected_generation)?;
3693        validate_command_ref(idempotency_ref, "idempotencyRef")?;
3694        if command_fingerprint.is_some() {
3695            self.ensure_command_result_capacity(idempotency_ref)?;
3696        }
3697        if turn_ref.trim().is_empty() {
3698            return Err(SarahConversationError::InvalidRequest(
3699                "turnRef must not be empty".into(),
3700            ));
3701        }
3702        let intent_ref = format!(
3703            "intent.interrupt.{}",
3704            &format!("{:x}", Sha256::digest(idempotency_ref.as_bytes()))[..24]
3705        );
3706        let conversation_ref = self.config.conversation_ref();
3707        let mut tags = conversation_tags(
3708            &conversation_ref,
3709            &self.config.identity.owner_public_key_hex,
3710            &self.config.identity.sarah_public_key_hex,
3711        )?;
3712        tags.push(
3713            Tag::parse(["turn", turn_ref])
3714                .map_err(|error| SarahConversationError::Internal(error.to_string()))?,
3715        );
3716        tags.push(
3717            Tag::parse(["control", "cancel_turn"])
3718                .map_err(|error| SarahConversationError::Internal(error.to_string()))?,
3719        );
3720        tags.push(
3721            Tag::parse(["idempotency", idempotency_ref])
3722                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
3723        );
3724        tags.push(
3725            Tag::parse(["generation", &expected_generation.to_string()])
3726                .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?,
3727        );
3728
3729        let content = json!({
3730            "schema": "openagents.sarah.control.v1",
3731            "control": "cancel_turn",
3732            "turnRef": turn_ref,
3733            "intentRef": intent_ref,
3734            "idempotencyRef": idempotency_ref,
3735            "expectedGeneration": expected_generation,
3736        })
3737        .to_string();
3738
3739        let mut public_event = None;
3740        let mut inserted_outbox_ref = None;
3741        if self.relay.requires_private_messages() {
3742            let recipients = private_recipients(
3743                &self.config.identity.owner_public_key_hex,
3744                &self.config.identity.sarah_public_key_hex,
3745            )?;
3746            let outbox_ref = private_outbox_ref("sarah.interrupt", &content, &tags, &recipients);
3747            let (_, inserted) =
3748                self.enqueue_private_content(&outbox_ref, &content, tags, &recipients)?;
3749            if inserted {
3750                inserted_outbox_ref = Some(outbox_ref);
3751            }
3752        } else {
3753            let event = match &self.signer {
3754                ConversationSigner::Keys(identity) => {
3755                    identity.sign_custom(NIP_AO_KIND, &content, tags)?
3756                }
3757                ConversationSigner::OmegaIdentity(_) => {
3758                    return Err(SarahConversationError::Internal(
3759                        "custodied production signer requires NIP-17 transport".into(),
3760                    ));
3761                }
3762            };
3763            public_event = Some(event);
3764        }
3765        let response = InterruptTurnResult {
3766            accepted: true,
3767            turn_ref: turn_ref.to_string(),
3768            intent_ref,
3769            status: "pending".to_string(),
3770            pending: true,
3771        };
3772        let response_value = command_fingerprint
3773            .as_ref()
3774            .map(|_| serde_json::to_value(&response))
3775            .transpose()
3776            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3777        let previous_run_state = self.run_state.clone();
3778        self.run_state = "interrupt_pending".to_string();
3779        if let (Some(fingerprint), Some(response_value)) =
3780            (command_fingerprint.as_ref(), response_value.as_ref())
3781        {
3782            self.command_results.insert(
3783                idempotency_ref.to_string(),
3784                (fingerprint.clone(), response_value.clone()),
3785            );
3786        }
3787        if let Err(error) = self.persist_issue31_host_state() {
3788            self.run_state = previous_run_state;
3789            if command_fingerprint.is_some() {
3790                self.command_results.remove(idempotency_ref);
3791            }
3792            if let Some(outbox_ref) = inserted_outbox_ref {
3793                self.issue31_private_outbox.remove(&outbox_ref);
3794            }
3795            return Err(error);
3796        }
3797        let publication = if let Some(event) = public_event.as_ref() {
3798            self.publish_with_auth(event)
3799        } else {
3800            self.flush_issue31_outbox()
3801        };
3802        let persistence = self.persist_issue31_host_state();
3803        finish_durable_operation(publication, persistence)?;
3804        self.push_room_state_event(&self.current_room_state());
3805        Ok(response)
3806    }
3807
3808    /// Drain pending framed events (`sarah_room_event` / `sarah_room_state`).
3809    pub fn drain_events(&mut self) -> Vec<Value> {
3810        self.pending_events.drain(..).collect()
3811    }
3812
3813    fn cached_command(
3814        &self,
3815        idempotency_ref: &str,
3816        fingerprint: &str,
3817    ) -> Result<Option<Value>, SarahConversationError> {
3818        match self.command_results.get(idempotency_ref) {
3819            Some((existing_fingerprint, result)) if existing_fingerprint == fingerprint => {
3820                Ok(Some(result.clone()))
3821            }
3822            Some(_) => Err(SarahConversationError::InvalidRequest(format!(
3823                "idempotencyRef {idempotency_ref} conflicts with an earlier command"
3824            ))),
3825            None => Ok(None),
3826        }
3827    }
3828
3829    fn ensure_command_result_capacity(
3830        &self,
3831        idempotency_ref: &str,
3832    ) -> Result<(), SarahConversationError> {
3833        if !self.command_results.contains_key(idempotency_ref)
3834            && self.command_results.len() >= MAX_COMMAND_RESULTS
3835        {
3836            return Err(SarahConversationError::InvalidRequest(
3837                "durable command result bound is exhausted".into(),
3838            ));
3839        }
3840        Ok(())
3841    }
3842
3843    fn retry_durable_outbox(&mut self) -> Result<(), SarahConversationError> {
3844        if self.issue31_discovery_outbox.is_none() && self.issue31_private_outbox.is_empty() {
3845            return Ok(());
3846        }
3847        self.ensure_connected()?;
3848        let publication = self.flush_issue31_outbox();
3849        let persistence = self.persist_issue31_host_state();
3850        finish_durable_operation(publication, persistence)
3851    }
3852
3853    pub fn encode_response_frame(
3854        &self,
3855        id: impl Into<String>,
3856        generation: u64,
3857        result: Value,
3858    ) -> Result<String, SarahConversationError> {
3859        let frame = json!({
3860            "schema": PROTOCOL_SCHEMA,
3861            "kind": "response",
3862            "id": id.into(),
3863            "generation": generation,
3864            "ok": true,
3865            "result": result,
3866        });
3867        let line = serde_json::to_string(&frame)
3868            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3869        if line.len() > MAX_FRAME_BYTES {
3870            return Err(SarahConversationError::Internal(
3871                "response frame exceeds 64 KiB cap".into(),
3872            ));
3873        }
3874        Ok(line)
3875    }
3876
3877    pub fn encode_event_frame(
3878        &self,
3879        generation: u64,
3880        method: &str,
3881        payload: Value,
3882    ) -> Result<String, SarahConversationError> {
3883        let frame = json!({
3884            "schema": PROTOCOL_SCHEMA,
3885            "kind": "event",
3886            "generation": generation,
3887            "method": method,
3888            "params": payload,
3889        });
3890        let line = serde_json::to_string(&frame)
3891            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
3892        if line.len() > MAX_FRAME_BYTES {
3893            return Err(SarahConversationError::Internal(
3894                "event frame exceeds 64 KiB cap".into(),
3895            ));
3896        }
3897        // Hard redaction: never allow token-shaped payloads into events.
3898        if line.contains("bearer ") || line.contains("sk-") {
3899            return Err(SarahConversationError::Internal(
3900                "event frame refused: secret-shaped content".into(),
3901            ));
3902        }
3903        Ok(line)
3904    }
3905
3906    fn ensure_generation(&self, generation: u64) -> Result<(), SarahConversationError> {
3907        if generation != self.config.generation {
3908            return Err(SarahConversationError::StaleGeneration {
3909                expected: self.config.generation,
3910                got: generation,
3911            });
3912        }
3913        Ok(())
3914    }
3915
3916    fn ensure_connected(&mut self) -> Result<(), SarahConversationError> {
3917        if self.relay.connection_state() == ConnectionState::Disconnected {
3918            self.relay.connect()?;
3919        }
3920        if let Some(challenge) = self.relay.auth_challenge() {
3921            let auth_event = self
3922                .signer
3923                .sign_auth(&challenge.challenge, &challenge.relay_url)?;
3924            self.relay.authenticate(&auth_event)?;
3925        }
3926        Ok(())
3927    }
3928
3929    fn authenticate_pending(&mut self) -> Result<(), SarahConversationError> {
3930        let challenge = self
3931            .relay
3932            .auth_challenge()
3933            .ok_or(SarahConversationError::IdentityRequired)?;
3934        let auth_event = self
3935            .signer
3936            .sign_auth(&challenge.challenge, &challenge.relay_url)?;
3937        self.relay.authenticate(&auth_event)
3938    }
3939
3940    fn publish_with_auth(&mut self, event: &Event) -> Result<(), SarahConversationError> {
3941        match self.relay.publish(event) {
3942            Ok(()) => Ok(()),
3943            Err(SarahConversationError::IdentityRequired) => {
3944                self.authenticate_pending()?;
3945                self.relay.publish(event)
3946            }
3947            Err(error) => Err(error),
3948        }
3949    }
3950
3951    fn query_with_auth(
3952        &mut self,
3953        conversation_ref: &str,
3954        cursor: Option<&str>,
3955        limit: usize,
3956    ) -> Result<QueryPage, SarahConversationError> {
3957        match self.relay.query(conversation_ref, cursor, limit) {
3958            Ok(page) => Ok(page),
3959            Err(SarahConversationError::IdentityRequired) => {
3960                self.authenticate_pending()?;
3961                self.relay.query(conversation_ref, cursor, limit)
3962            }
3963            Err(error) => Err(error),
3964        }
3965    }
3966
3967    fn current_room_state(&self) -> RoomStateEvent {
3968        let last_id = self.relay.last_event_id();
3969        let gap_state = strongest_gap_state(self.last_gap_state, self.relay.gap_state());
3970        RoomStateEvent {
3971            method: SARAH_EVENT_ROOM_STATE.to_string(),
3972            connection: self.relay.connection_state(),
3973            freshness: if self.relay.connection_state() == ConnectionState::Connected
3974                && gap_state == GapState::None
3975            {
3976                FreshnessState::Fresh
3977            } else {
3978                FreshnessState::Unknown
3979            },
3980            gap_state,
3981            connected_relays: self.relay.connected_relays(),
3982            last_acknowledged_event_id: last_id,
3983            last_acknowledged_cursor: self.last_confirmed_cursor.clone(),
3984            authenticated: self.relay.is_authenticated(),
3985            transport: self.transport_label(),
3986        }
3987    }
3988
3989    fn transport_label(&self) -> String {
3990        if self.config.relay_url.is_some() {
3991            "nostr_relay".to_string()
3992        } else {
3993            "mock_relay".to_string()
3994        }
3995    }
3996
3997    fn push_room_event(&mut self, record: &TranscriptEntry) {
3998        let payload = RoomEventPayload {
3999            method: SARAH_EVENT_ROOM_EVENT.to_string(),
4000            conversation_ref: self.config.conversation_ref(),
4001            cursor: record.cursor.clone(),
4002            record: record.clone(),
4003        };
4004        if let Ok(value) = serde_json::to_value(payload) {
4005            if self.pending_events.len() >= MAX_PENDING_EVENTS {
4006                self.pending_events.pop_front();
4007            }
4008            self.pending_events.push_back(value);
4009        }
4010    }
4011
4012    fn push_room_state_event(&mut self, state: &RoomStateEvent) {
4013        if let Ok(value) = serde_json::to_value(state) {
4014            if self.pending_events.len() >= MAX_PENDING_EVENTS {
4015                self.pending_events.pop_front();
4016            }
4017            self.pending_events.push_back(value);
4018        }
4019    }
4020}
4021
4022fn finish_durable_operation<T>(
4023    operation: Result<T, SarahConversationError>,
4024    persistence: Result<(), SarahConversationError>,
4025) -> Result<T, SarahConversationError> {
4026    match (operation, persistence) {
4027        (Ok(value), Ok(())) => Ok(value),
4028        (Err(error), Ok(())) => Err(error),
4029        (Ok(_), Err(error)) => Err(error),
4030        (Err(operation_error), Err(persistence_error)) => Err(SarahConversationError::Internal(
4031            format!("{operation_error}; durable Issue 31 commit also failed: {persistence_error}"),
4032        )),
4033    }
4034}
4035
4036fn durable_private_outbox_into_runtime(
4037    durable: BTreeMap<String, DurableIssue31PrivatePublish>,
4038) -> Result<BTreeMap<String, PendingIssue31PrivatePublish>, SarahConversationError> {
4039    if durable.len() > MAX_PRIVATE_OUTBOX_ITEMS {
4040        return Err(SarahConversationError::Internal(
4041            "durable Issue 31 outbox exceeds its item bound".into(),
4042        ));
4043    }
4044    durable
4045        .into_iter()
4046        .map(|(outbox_ref, pending)| {
4047            if outbox_ref.len() > 192
4048                || !is_lower_hex_64(&pending.rumor_event_id)
4049                || pending.gift_wrap_event_json.is_empty()
4050                || pending.gift_wrap_event_json.len() > 8
4051            {
4052                return Err(SarahConversationError::Internal(
4053                    "durable Issue 31 outbox item is invalid".into(),
4054                ));
4055            }
4056            let gift_wraps = pending
4057                .gift_wrap_event_json
4058                .into_iter()
4059                .map(|event_json| {
4060                    let event = Event::from_json(event_json)
4061                        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4062                    if event.kind != Kind::GiftWrap || event.verify().is_err() {
4063                        return Err(SarahConversationError::Internal(
4064                            "durable Issue 31 outbox has an invalid gift wrap".into(),
4065                        ));
4066                    }
4067                    Ok(event)
4068                })
4069                .collect::<Result<Vec<_>, _>>()?;
4070            Ok((
4071                outbox_ref,
4072                PendingIssue31PrivatePublish {
4073                    rumor_event_id: pending.rumor_event_id,
4074                    gift_wraps,
4075                },
4076            ))
4077        })
4078        .collect()
4079}
4080
4081fn load_issue31_host_state(
4082    path: &Path,
4083    expected_configuration: &Issue31HostConfiguration,
4084) -> Result<Option<DurableIssue31HostState>, SarahConversationError> {
4085    let metadata = match fs::symlink_metadata(path) {
4086        Ok(metadata) => metadata,
4087        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4088        Err(error) => return Err(SarahConversationError::Internal(error.to_string())),
4089    };
4090    if !metadata.file_type().is_file()
4091        || metadata.file_type().is_symlink()
4092        || metadata.len() > ISSUE31_DURABLE_STATE_MAX_BYTES
4093    {
4094        return Err(SarahConversationError::Internal(
4095            "durable Issue 31 host state is unsafe or oversized".into(),
4096        ));
4097    }
4098    let file = OpenOptions::new()
4099        .read(true)
4100        .open(path)
4101        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4102    let mut bytes = Vec::with_capacity(metadata.len() as usize);
4103    file.take(ISSUE31_DURABLE_STATE_MAX_BYTES.saturating_add(1))
4104        .read_to_end(&mut bytes)
4105        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4106    if bytes.len() as u64 > ISSUE31_DURABLE_STATE_MAX_BYTES {
4107        return Err(SarahConversationError::Internal(
4108            "durable Issue 31 host state exceeds its byte bound".into(),
4109        ));
4110    }
4111    let mut state: DurableIssue31HostState = serde_json::from_slice(&bytes)
4112        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4113    state
4114        .controller
4115        .adopt_conversation_if_missing(&expected_configuration.conversation)
4116        .map_err(issue31_error)?;
4117    if state.schema != ISSUE31_DURABLE_STATE_SCHEMA
4118        || !state
4119            .controller
4120            .matches_configuration(expected_configuration)
4121        || state.discovery_generation.is_some() != state.discovery_expires_at.is_some()
4122        || state.command_results.len() > MAX_COMMAND_RESULTS
4123        || state.relay_acknowledgements.len() > MAX_RELAY_ACKNOWLEDGEMENTS
4124        || state.quarantined_events.len() > MAX_QUARANTINED_ISSUE31_EVENTS
4125        || state.host_adjunct_emissions.len() > MAX_QUARANTINED_ISSUE31_EVENTS
4126        || state
4127            .control_cursor
4128            .as_deref()
4129            .is_some_and(|cursor| !valid_event_cursor(cursor))
4130        || state
4131            .projection_cursor
4132            .as_deref()
4133            .is_some_and(|cursor| !valid_event_cursor(cursor))
4134        || state
4135            .active_turn_ref
4136            .as_deref()
4137            .is_some_and(|turn_ref| !crate::is_issue31_public_ref(turn_ref))
4138        || !valid_run_state(&state.run_state)
4139    {
4140        return Err(SarahConversationError::Internal(
4141            "durable Issue 31 host state does not match this identity or relay configuration"
4142                .into(),
4143        ));
4144    }
4145    state
4146        .controller
4147        .validate_persisted_state()
4148        .map_err(issue31_error)?;
4149    if state
4150        .quarantined_events
4151        .iter()
4152        .any(|(event_id, reason_ref)| {
4153            !is_lower_hex_64(event_id) || !crate::is_issue31_public_ref(reason_ref)
4154        })
4155    {
4156        return Err(SarahConversationError::Internal(
4157            "durable Issue 31 quarantine contains an invalid record".into(),
4158        ));
4159    }
4160    if state
4161        .relay_acknowledgements
4162        .iter()
4163        .any(|(event_id, relay_urls)| {
4164            !is_lower_hex_64(event_id)
4165                || relay_urls.len() > expected_configuration.relay_urls.len()
4166                || relay_urls.iter().enumerate().any(|(index, relay_url)| {
4167                    relay_urls[..index].iter().any(|prior| prior == relay_url)
4168                })
4169                || relay_urls
4170                    .iter()
4171                    .any(|relay_url| !expected_configuration.relay_urls.contains(relay_url))
4172        })
4173    {
4174        return Err(SarahConversationError::Internal(
4175            "durable Issue 31 relay acknowledgements are invalid".into(),
4176        ));
4177    }
4178    if let Some(event_json) = state.discovery_event_json.clone() {
4179        let event = Event::from_json(&event_json)
4180            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4181        let value = serde_json::from_str::<Value>(&event.content)
4182            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4183        let is_v2 = value.get("schema").and_then(Value::as_str)
4184            == Some(crate::ISSUE31_HOST_DISCOVERY_SCHEMA_V2);
4185        let (generation, expires_at, host_ref, host_key, sarah_key, display_name, relay_urls) =
4186            if is_v2 {
4187                let discovery = Issue31HostDiscoveryV2::decode(event.content.as_bytes())
4188                    .map_err(issue31_error)?;
4189                if discovery.conversation != expected_configuration.conversation {
4190                    return Err(SarahConversationError::Internal(
4191                        "durable Issue 31 discovery binds another conversation".into(),
4192                    ));
4193                }
4194                (
4195                    discovery.generation,
4196                    discovery.expires_at,
4197                    discovery.host_ref,
4198                    discovery.host_public_key_hex,
4199                    discovery.sarah_public_key_hex,
4200                    discovery.display_name,
4201                    discovery.relay_urls,
4202                )
4203            } else {
4204                let discovery = Issue31HostDiscovery::decode(event.content.as_bytes())
4205                    .map_err(issue31_error)?;
4206                (
4207                    discovery.generation,
4208                    discovery.expires_at,
4209                    discovery.host_ref,
4210                    discovery.host_public_key_hex,
4211                    discovery.sarah_public_key_hex,
4212                    discovery.display_name,
4213                    discovery.relay_urls,
4214                )
4215            };
4216        if event.kind.as_u16() != ISSUE31_HOST_DISCOVERY_KIND
4217            || event.pubkey.to_hex() != expected_configuration.host_public_key_hex
4218            || event.verify().is_err()
4219            || state.discovery_generation != Some(generation)
4220            || state.discovery_expires_at != Some(expires_at)
4221            || host_ref != expected_configuration.host_ref
4222            || host_key != expected_configuration.host_public_key_hex
4223            || sarah_key != expected_configuration.sarah_public_key_hex
4224            || display_name != expected_configuration.display_name
4225            || relay_urls != expected_configuration.relay_urls
4226            || generation != expected_configuration.generation
4227        {
4228            return Err(SarahConversationError::Internal(
4229                "durable Issue 31 discovery outbox event is invalid".into(),
4230            ));
4231        }
4232        if !is_v2 {
4233            state.discovery_generation = None;
4234            state.discovery_expires_at = None;
4235            state.discovery_event_json = None;
4236            state.relay_acknowledgements.remove(&event.id.to_hex());
4237        }
4238    }
4239    durable_private_outbox_into_runtime(
4240        state
4241            .private_outbox
4242            .iter()
4243            .map(|(key, value)| {
4244                (
4245                    key.clone(),
4246                    DurableIssue31PrivatePublish {
4247                        rumor_event_id: value.rumor_event_id.clone(),
4248                        gift_wrap_event_json: value.gift_wrap_event_json.clone(),
4249                    },
4250                )
4251            })
4252            .collect(),
4253    )?;
4254    Ok(Some(state))
4255}
4256
4257fn write_issue31_host_state(
4258    path: &Path,
4259    state: &DurableIssue31HostState,
4260) -> Result<(), SarahConversationError> {
4261    let bytes = serde_json::to_vec(state)
4262        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4263    if bytes.len() as u64 > ISSUE31_DURABLE_STATE_MAX_BYTES {
4264        return Err(SarahConversationError::Internal(
4265            "durable Issue 31 host state exceeds its byte bound".into(),
4266        ));
4267    }
4268    let parent = path.parent().ok_or_else(|| {
4269        SarahConversationError::Internal("Issue 31 state path has no parent".into())
4270    })?;
4271    fs::create_dir_all(parent)
4272        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4273    #[cfg(unix)]
4274    {
4275        use std::os::unix::fs::PermissionsExt as _;
4276        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
4277            .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4278    }
4279    let file_name = path
4280        .file_name()
4281        .and_then(|name| name.to_str())
4282        .ok_or_else(|| {
4283            SarahConversationError::Internal("Issue 31 state filename is invalid".into())
4284        })?;
4285    let temporary_path = parent.join(format!(".{file_name}.{}.tmp", rand::random::<u64>()));
4286    let mut options = OpenOptions::new();
4287    options.write(true).create_new(true);
4288    #[cfg(unix)]
4289    {
4290        use std::os::unix::fs::OpenOptionsExt as _;
4291        options.mode(0o600);
4292    }
4293    let mut file = options
4294        .open(&temporary_path)
4295        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4296    let write_result = file
4297        .write_all(&bytes)
4298        .and_then(|()| file.sync_all())
4299        .and_then(|()| fs::rename(&temporary_path, path));
4300    if let Err(error) = write_result {
4301        return match fs::remove_file(&temporary_path) {
4302            Ok(()) => Err(SarahConversationError::Internal(error.to_string())),
4303            Err(cleanup_error) if cleanup_error.kind() == std::io::ErrorKind::NotFound => {
4304                Err(SarahConversationError::Internal(error.to_string()))
4305            }
4306            Err(cleanup_error) => Err(SarahConversationError::Internal(format!(
4307                "{error}; temporary state cleanup failed: {cleanup_error}"
4308            ))),
4309        };
4310    }
4311    Ok(())
4312}
4313
4314fn is_lower_hex_64(value: &str) -> bool {
4315    value.len() == 64
4316        && value
4317            .bytes()
4318            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
4319}
4320
4321fn valid_event_cursor(value: &str) -> bool {
4322    let mut parts = value.split('.');
4323    matches!(parts.next(), Some("cursor"))
4324        && parts
4325            .next()
4326            .is_some_and(|created_at| created_at.parse::<u64>().is_ok())
4327        && parts.next().is_some_and(is_lower_hex_64)
4328        && parts.next().is_none()
4329}
4330
4331fn default_run_state() -> String {
4332    "idle".to_string()
4333}
4334
4335fn confirmed_nostr_projection_kind(kind: u16) -> bool {
4336    matches!(kind, 9 | 5_934 | 6_934 | 7_000 | 30_078 | 30_174 | 30_300)
4337}
4338
4339fn public_key_fingerprint(public_key_hex: &str) -> String {
4340    let digest = format!("{:x}", Sha256::digest(public_key_hex.as_bytes()));
4341    digest[..16].to_ascii_uppercase()
4342}
4343
4344fn valid_run_state(value: &str) -> bool {
4345    matches!(
4346        value,
4347        "idle" | "running" | "interrupt_pending" | "stopped" | "paused"
4348    )
4349}
4350
4351fn private_outbox_ref(prefix: &str, content: &str, tags: &[Tag], recipients: &[String]) -> String {
4352    let mut binding = Vec::with_capacity(
4353        prefix.len()
4354            + content.len()
4355            + tags
4356                .iter()
4357                .flat_map(|tag| tag.as_slice())
4358                .map(String::len)
4359                .sum::<usize>()
4360            + recipients.iter().map(String::len).sum::<usize>()
4361            + tags.len()
4362            + recipients.len(),
4363    );
4364    binding.extend_from_slice(prefix.as_bytes());
4365    binding.push(0);
4366    binding.extend_from_slice(content.as_bytes());
4367    for tag in tags {
4368        binding.push(0xff);
4369        for value in tag.as_slice() {
4370            binding.push(0);
4371            binding.extend_from_slice(value.as_bytes());
4372        }
4373    }
4374    for recipient in recipients {
4375        binding.push(0);
4376        binding.extend_from_slice(recipient.as_bytes());
4377    }
4378    format!("{prefix}.{:x}", Sha256::digest(binding))
4379}
4380
4381pub(crate) fn conversation_tags(
4382    conversation_ref: &str,
4383    owner_pubkey: &str,
4384    sarah_pubkey: &str,
4385) -> Result<Vec<Tag>, SarahConversationError> {
4386    if owner_pubkey == sarah_pubkey {
4387        return Err(SarahConversationError::InvalidRequest(
4388            "owner and Sarah identities must be distinct".into(),
4389        ));
4390    }
4391    [
4392        ["conversation", conversation_ref],
4393        ["p", owner_pubkey],
4394        ["p", sarah_pubkey],
4395        ["agent", sarah_pubkey],
4396        ["alt", "OpenAgents Sarah conversation message"],
4397    ]
4398    .into_iter()
4399    .map(|tag| {
4400        Tag::parse(tag).map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))
4401    })
4402    .collect()
4403}
4404
4405fn stored_tag_value(tags: &[Vec<String>], name: &str) -> Option<String> {
4406    tags.iter().find_map(|tag| {
4407        (tag.first().map(String::as_str) == Some(name))
4408            .then(|| tag.get(1).cloned())
4409            .flatten()
4410    })
4411}
4412
4413fn private_recipients(
4414    owner_pubkey: &str,
4415    sarah_pubkey: &str,
4416) -> Result<Vec<String>, SarahConversationError> {
4417    if owner_pubkey == sarah_pubkey {
4418        return Err(SarahConversationError::InvalidRequest(
4419            "owner and Sarah identities must be distinct".into(),
4420        ));
4421    }
4422    PublicKey::from_hex(owner_pubkey)
4423        .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
4424    PublicKey::from_hex(sarah_pubkey)
4425        .map_err(|error| SarahConversationError::InvalidRequest(error.to_string()))?;
4426    Ok(vec![owner_pubkey.to_string(), sarah_pubkey.to_string()])
4427}
4428
4429fn digest_receipt_ref(
4430    prefix: &str,
4431    semantic_binding: &[u8],
4432) -> Result<ReceiptRef, SarahConversationError> {
4433    let digest = format!("{:x}", Sha256::digest(semantic_binding));
4434    ReceiptRef::new(format!("{prefix}.{}", &digest[..32]))
4435        .map_err(|error| SarahConversationError::Identity(error.to_string()))
4436}
4437
4438fn issue31_error(error: Issue31NostrError) -> SarahConversationError {
4439    SarahConversationError::InvalidRequest(error.to_string())
4440}
4441
4442fn strongest_gap_state(left: GapState, right: GapState) -> GapState {
4443    let severity = |state| match state {
4444        GapState::None => 0,
4445        GapState::Possible => 1,
4446        GapState::Recovering => 2,
4447        GapState::Confirmed => 3,
4448    };
4449    if severity(left) >= severity(right) {
4450        left
4451    } else {
4452        right
4453    }
4454}
4455
4456fn command_binding(
4457    params: Option<&Value>,
4458    framed_generation: u64,
4459) -> Result<(String, u64), SarahConversationError> {
4460    let idempotency_ref = params
4461        .and_then(|value| value.get("idempotencyRef"))
4462        .and_then(Value::as_str)
4463        .ok_or_else(|| {
4464            SarahConversationError::InvalidRequest("host command requires idempotencyRef".into())
4465        })?;
4466    validate_command_ref(idempotency_ref, "idempotencyRef")?;
4467    let expected_generation = params
4468        .and_then(|value| value.get("expectedGeneration"))
4469        .and_then(Value::as_u64)
4470        .ok_or_else(|| {
4471            SarahConversationError::InvalidRequest(
4472                "host command requires expectedGeneration".into(),
4473            )
4474        })?;
4475    if expected_generation != framed_generation {
4476        return Err(SarahConversationError::StaleGeneration {
4477            expected: framed_generation,
4478            got: expected_generation,
4479        });
4480    }
4481    Ok((idempotency_ref.to_string(), expected_generation))
4482}
4483
4484fn validate_command_ref(value: &str, field: &str) -> Result<(), SarahConversationError> {
4485    if !crate::is_issue31_public_ref(value) {
4486        return Err(SarahConversationError::InvalidRequest(format!(
4487            "{field} must match the bounded Issue 31 PublicRef grammar"
4488        )));
4489    }
4490    Ok(())
4491}
4492
4493fn command_fingerprint(
4494    method: &str,
4495    params: Option<&Value>,
4496) -> Result<String, SarahConversationError> {
4497    let canonical = serde_json::to_vec(&json!({ "method": method, "params": params }))
4498        .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
4499    Ok(format!("{:x}", Sha256::digest(canonical)))
4500}
4501
4502fn tag_value(tags: &[Vec<String>], name: &str) -> Option<String> {
4503    tags.iter().find_map(|tag| {
4504        if tag.first().map(String::as_str) == Some(name) {
4505            tag.get(1).cloned()
4506        } else {
4507            None
4508        }
4509    })
4510}
4511
4512fn stored_event_cursor(event: &StoredConversationEvent) -> String {
4513    format!("{CURSOR_PREFIX}{}.{}", event.created_at, event.event_id)
4514}
4515
4516fn stream_next_cursor(
4517    last_stream_cursor: Option<&str>,
4518    upstream_next_cursor: Option<&str>,
4519    more_stream_entries_in_page: bool,
4520) -> Option<String> {
4521    if !more_stream_entries_in_page && upstream_next_cursor.is_none() {
4522        return None;
4523    }
4524    last_stream_cursor
4525        .or(upstream_next_cursor)
4526        .map(str::to_owned)
4527}
4528
4529fn unix_now() -> u64 {
4530    SystemTime::now()
4531        .duration_since(UNIX_EPOCH)
4532        .map(|duration| duration.as_secs())
4533        .unwrap_or(0)
4534}
4535
4536fn iso_from_unix(seconds: u64) -> String {
4537    // Keep dependency-free timestamps for public-safe projections.
4538    format!("{seconds}")
4539}
4540
4541fn redact_content_summary(content: &str) -> String {
4542    if looks_like_secret(content) {
4543        return "[redacted]".to_string();
4544    }
4545    const MAX: usize = 512;
4546    if content.len() <= MAX {
4547        content.to_string()
4548    } else {
4549        let mut boundary = MAX;
4550        while !content.is_char_boundary(boundary) {
4551            boundary -= 1;
4552        }
4553        format!("{}…", &content[..boundary])
4554    }
4555}
4556
4557fn looks_like_secret(text: &str) -> bool {
4558    let lower = text.to_ascii_lowercase();
4559    lower.contains("bearer ")
4560        || lower.contains("authorization:")
4561        || lower.contains("api_key")
4562        || lower.contains("sk-")
4563        || lower.contains("-----begin ")
4564}
4565
4566/// Assert the crate graph does not pull a Khala Sync client for this lane.
4567pub fn asserts_no_khala_sync_client() -> bool {
4568    // Compile-time documentation of the cut OMEGA-SW-02 rule. The Sarah lane
4569    // must remain Nostr-only; this helper is used by unit tests as a living
4570    // guardrail comment surface.
4571    true
4572}
4573
4574#[cfg(test)]
4575mod tests {
4576    use std::sync::Arc;
4577    use std::sync::atomic::{AtomicUsize, Ordering};
4578
4579    use super::*;
4580
4581    fn client() -> SarahConversationClient {
4582        SarahConversationClient::new_mock(SarahConversationConfig::mock_fixture())
4583    }
4584
4585    #[test]
4586    fn framed_method_names_match_spec_section_8() {
4587        assert_eq!(
4588            SARAH_FRAMED_METHODS,
4589            &[
4590                "sarah_session_status",
4591                "sarah_bootstrap",
4592                "sarah_room_snapshot",
4593                "sarah_send_message",
4594                "sarah_interrupt_turn",
4595                "sarah_device_grants",
4596                "sarah_renew_device_grant",
4597                "sarah_revoke_device_grant",
4598                "sarah_readmit_device",
4599            ]
4600        );
4601        assert_eq!(SARAH_EVENT_ROOM_EVENT, "sarah_room_event");
4602        assert_eq!(SARAH_EVENT_ROOM_STATE, "sarah_room_state");
4603    }
4604
4605    #[test]
4606    fn session_status_never_returns_token_fields() {
4607        let mut client = client();
4608        let status = client.session_status().expect("status");
4609        let encoded = serde_json::to_string(&status).expect("json");
4610        assert!(status.signed_in);
4611        assert_eq!(status.account_label.as_deref(), Some("owner@example.com"));
4612        assert!(!encoded.contains("token"));
4613        assert!(!encoded.contains("bearer"));
4614        assert!(!encoded.contains("secret"));
4615    }
4616
4617    #[test]
4618    fn bootstrap_projects_conversation_and_room_state() {
4619        let mut client = client();
4620        let boot = client.bootstrap().expect("bootstrap");
4621        assert_eq!(boot.principal_ref, "principal.sarah");
4622        assert!(boot.conversation_ref.starts_with("sarah."));
4623        assert!(boot.legacy_thread_ref.starts_with("thread.sarah."));
4624        assert_eq!(boot.room_state.connection, ConnectionState::Connected);
4625        assert_eq!(boot.room_state.transport, "mock_relay");
4626        assert!(boot.room_state.authenticated);
4627        let events = client.drain_events();
4628        assert!(
4629            events.iter().any(|event| {
4630                event.get("method").and_then(Value::as_str) == Some(SARAH_EVENT_ROOM_STATE)
4631            }),
4632            "bootstrap must emit sarah_room_state"
4633        );
4634    }
4635
4636    #[test]
4637    fn send_and_snapshot_page_with_cursors() {
4638        let mut client = client();
4639        client.bootstrap().expect("bootstrap");
4640        let sent = client
4641            .send_message("Plan the next SARAH-NR packet", "idem.send.1", 1)
4642            .expect("send");
4643        assert!(sent.accepted);
4644        assert!(sent.message_ref.starts_with("msg."));
4645        assert!(sent.turn_ref.starts_with("turn."));
4646        assert!(!sent.event_id.is_empty());
4647
4648        let snap = client.room_snapshot(None, Some(10)).expect("snapshot");
4649        assert_eq!(snap.conversation_ref, client.conversation_ref());
4650        assert_eq!(snap.transcript.gap_state, GapState::None);
4651        assert!(!snap.transcript.cursor.is_empty());
4652        assert_eq!(snap.transcript.entries.len(), 1);
4653        assert_eq!(snap.transcript.entries[0].role, "owner");
4654        assert_eq!(
4655            snap.transcript.entries[0].text,
4656            "Plan the next SARAH-NR packet"
4657        );
4658        assert_eq!(snap.run_state.state, "running");
4659
4660        let conversation_ref = client.conversation_ref();
4661        let published = client
4662            .relay
4663            .query(&conversation_ref, None, 10)
4664            .expect("published message");
4665        let message = published.events.last().expect("message event");
4666        assert!(message.tags.iter().any(|tag| {
4667            tag.first().map(String::as_str) == Some("turn")
4668                && tag.get(1).map(String::as_str) == Some(sent.turn_ref.as_str())
4669        }));
4670
4671        let events = client.drain_events();
4672        assert!(
4673            events.iter().any(|event| {
4674                event.get("method").and_then(Value::as_str) == Some(SARAH_EVENT_ROOM_EVENT)
4675            }),
4676            "send must emit sarah_room_event"
4677        );
4678    }
4679
4680    #[test]
4681    fn interrupt_is_pending_until_settled() {
4682        let mut client = client();
4683        client.bootstrap().expect("bootstrap");
4684        let sent = client
4685            .send_message("start a turn", "idem.send.2", 1)
4686            .expect("send");
4687        let interrupt = client
4688            .interrupt_turn(&sent.turn_ref, "idem.interrupt.1", 1)
4689            .expect("interrupt");
4690        assert!(interrupt.accepted);
4691        assert!(interrupt.pending);
4692        assert_eq!(interrupt.status, "pending");
4693        assert_eq!(interrupt.turn_ref, sent.turn_ref);
4694    }
4695
4696    #[test]
4697    fn issue31_owner_state_actions_publish_encrypted_mergeable_sources() {
4698        let mut client = client();
4699        client.bootstrap().expect("bootstrap");
4700        let context_ref = "sarah-conversation:sarah.aaaaaaaaaaaaaaaaaaaaaaaa";
4701        let read_arguments = |read_at| Issue31CommandArguments::ReadStatePatch {
4702            action_ref: "action.issue31.read_state.advance".into(),
4703            slot_id: "owner-mobile".into(),
4704            client_id: "iphone".into(),
4705            context_ref: context_ref.into(),
4706            read_at,
4707        };
4708        let (_, first_projection) = client
4709            .execute_issue31_owner_state_action(&read_arguments(100))
4710            .expect("first read-state write");
4711        let (second_event_id, second_projection) = client
4712            .execute_issue31_owner_state_action(&read_arguments(200))
4713            .expect("merged read-state write");
4714        assert_ne!(second_event_id, "0".repeat(64));
4715        assert!(matches!(
4716            first_projection,
4717            Issue31OwnerProjectionBody::ReadState { .. }
4718        ));
4719        let Issue31OwnerProjectionBody::ReadState { plaintext, .. } = second_projection else {
4720            panic!("read-state projection");
4721        };
4722        let value: Value = serde_json::from_str(&plaintext).expect("read-state plaintext");
4723        assert_eq!(value["contexts"][context_ref], 200);
4724        let contexts = client
4725            .load_issue31_read_state_contexts("read-state:owner-mobile")
4726            .expect("reload read-state contexts");
4727        assert_eq!(contexts.get(context_ref), Some(&200));
4728
4729        let reminder_id = "a".repeat(32);
4730        let (_, reminder_projection) = client
4731            .execute_issue31_owner_state_action(&Issue31CommandArguments::ReminderCreate {
4732                action_ref: "action.issue31.reminder.create".into(),
4733                reminder_id: reminder_id.clone(),
4734                note: Some("Review the signed build".into()),
4735                target_event_id: None,
4736                not_before: 300,
4737                expiration: Some(600),
4738            })
4739            .expect("reminder write");
4740        assert!(matches!(
4741            reminder_projection,
4742            Issue31OwnerProjectionBody::Reminder {
4743                reminder_id: projected_id,
4744                not_before: Some(300),
4745                expiration: Some(600),
4746                ..
4747            } if projected_id == reminder_id
4748        ));
4749        let conversation_ref = client.config.conversation_ref();
4750        let page = client
4751            .query_with_auth(&conversation_ref, None, MAX_PAGE_LIMIT)
4752            .expect("query encrypted sources");
4753        assert!(page.events.iter().any(|event| {
4754            event.kind == SARAH_READ_STATE_KIND
4755                && !event.content_summary.contains("client_id")
4756                && !event.content_summary.contains(context_ref)
4757        }));
4758        assert!(page.events.iter().any(|event| {
4759            event.kind == SARAH_REMINDER_KIND
4760                && !event.content_summary.contains("Review the signed build")
4761        }));
4762    }
4763
4764    #[test]
4765    fn stale_generation_fails_closed() {
4766        let mut client = client();
4767        let error = client
4768            .handle_request(SARAH_METHOD_SESSION_STATUS, 99, None)
4769            .expect_err("stale");
4770        match error {
4771            SarahConversationError::StaleGeneration { expected, got } => {
4772                assert_eq!(expected, 1);
4773                assert_eq!(got, 99);
4774            }
4775            other => panic!("expected stale generation, got {other}"),
4776        }
4777    }
4778
4779    #[test]
4780    fn process_generation_advances_without_changing_nostr_host_generation() {
4781        let mut client = client();
4782        client
4783            .synchronize_process_generation(4)
4784            .expect("advance trusted process fence");
4785        assert_eq!(client.generation(), 4);
4786        client
4787            .handle_request(SARAH_METHOD_SESSION_STATUS, 4, None)
4788            .expect("advanced generation request");
4789        assert!(matches!(
4790            client.synchronize_process_generation(3),
4791            Err(SarahConversationError::StaleGeneration {
4792                expected: 4,
4793                got: 3
4794            })
4795        ));
4796    }
4797
4798    /// A source event the host itself signed can still carry a body the device
4799    /// reader refuses. Before the emitter owned that decision, one such event
4800    /// aborted the whole projection pass, so a single malformed record stopped
4801    /// every later record reaching every paired device. The bad event is
4802    /// quarantined and the pass continues.
4803    #[test]
4804    fn one_refused_projection_source_is_quarantined_without_stopping_the_pass() {
4805        let signer = SigningIdentity::generate();
4806        let mut config = SarahConversationConfig::mock_fixture();
4807        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
4808        let owner_public_key_hex = signer.public_key_hex.clone();
4809        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
4810        let conversation_ref = config.conversation_ref();
4811        let mut relay = MockRelayAdapter::new();
4812        for (index, (event_id, created_at, text)) in [
4813            ("1".repeat(64), 10_u64, "before"),
4814            // An empty message body is inside every record-level bound and
4815            // outside the projection body contract.
4816            ("2".repeat(64), 11, ""),
4817            ("3".repeat(64), 12, "after"),
4818        ]
4819        .into_iter()
4820        .enumerate()
4821        {
4822            relay.seed_event(StoredConversationEvent {
4823                event_id,
4824                kind: crate::ISSUE31_PRIVATE_RUMOR_KIND,
4825                pubkey: owner_public_key_hex.clone(),
4826                created_at,
4827                conversation_ref: conversation_ref.clone(),
4828                content_summary: text.into(),
4829                tags: Vec::new(),
4830                record_kind: "message".into(),
4831                store_index: index,
4832            });
4833        }
4834        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
4835
4836        let host_configuration = Issue31HostConfiguration {
4837            host_ref: "omega.host.local".into(),
4838            host_public_key_hex: owner_public_key_hex.clone(),
4839            sarah_public_key_hex,
4840            conversation: conversation_ref,
4841            display_name: "Omega host".into(),
4842            relay_urls: vec!["wss://relay.example.com".into()],
4843            generation: 1,
4844        };
4845        let device_public_key_hex = "2".repeat(64);
4846        let mut controller =
4847            Issue31HostController::new(host_configuration.clone()).expect("host controller");
4848        controller
4849            .set_admitted_device_policy(
4850                vec![device_public_key_hex.clone()],
4851                vec![crate::Issue31PairingScope::ObserveIssue31],
4852            )
4853            .expect("admit the device");
4854        let challenge = controller
4855            .handle_pairing_event(
4856                Issue31PairingEvent {
4857                    event_id: "a".repeat(64),
4858                    record: Issue31PairingRecord::PairingRequest {
4859                        schema: crate::ISSUE31_PAIRING_SCHEMA.into(),
4860                        host_ref: host_configuration.host_ref.clone(),
4861                        host_public_key_hex: owner_public_key_hex.clone(),
4862                        device_public_key_hex: device_public_key_hex.clone(),
4863                        issued_at: 100,
4864                        pairing_request_ref: "pairing_request.projection".into(),
4865                        requested_scopes: vec![crate::Issue31PairingScope::ObserveIssue31],
4866                        expires_at: 700,
4867                    },
4868                },
4869                100,
4870            )
4871            .expect("pairing request")
4872            .expect("pairing challenge");
4873        let Issue31PairingRecord::PairingChallenge {
4874            challenge: challenge_value,
4875            ..
4876        } = &challenge
4877        else {
4878            panic!("expected a pairing challenge");
4879        };
4880        let challenge_value = challenge_value.clone();
4881        controller
4882            .record_emitted_pairing("b".repeat(64), challenge)
4883            .expect("record the challenge");
4884        let grant = controller
4885            .handle_pairing_event(
4886                Issue31PairingEvent {
4887                    event_id: "c".repeat(64),
4888                    record: Issue31PairingRecord::PairingResponse {
4889                        schema: crate::ISSUE31_PAIRING_SCHEMA.into(),
4890                        host_ref: host_configuration.host_ref,
4891                        host_public_key_hex: owner_public_key_hex,
4892                        device_public_key_hex,
4893                        issued_at: 110,
4894                        pairing_response_ref: "pairing_response.projection".into(),
4895                        pairing_challenge_event_id: "b".repeat(64),
4896                        challenge: challenge_value,
4897                        expires_at: 700,
4898                    },
4899                },
4900                110,
4901            )
4902            .expect("pairing response")
4903            .expect("scoped grant");
4904        controller
4905            .record_emitted_pairing("d".repeat(64), grant)
4906            .expect("record the grant");
4907        assert_eq!(controller.active_grants(120).expect("grants").len(), 1);
4908
4909        client.ensure_connected().expect("connect the mock relay");
4910        client
4911            .project_issue31_sources(&mut controller, 200)
4912            .expect("the pass survives a refused source");
4913
4914        assert_eq!(
4915            client
4916                .issue31_quarantined_events
4917                .get(&"2".repeat(64))
4918                .map(String::as_str),
4919            Some("reason.omega.invalid_projection_source")
4920        );
4921        let grant_ref = controller.active_grants(200).expect("grants")[0]
4922            .grant_ref
4923            .clone();
4924        assert!(controller.source_was_projected(&grant_ref, 1, &"1".repeat(64)));
4925        assert!(!controller.source_was_projected(&grant_ref, 1, &"2".repeat(64)));
4926        assert!(controller.source_was_projected(&grant_ref, 1, &"3".repeat(64)));
4927        // Two surviving projections, plus the coverage statement that tells the
4928        // device the third source was withheld.
4929        assert_eq!(client.issue31_private_outbox.len(), 3);
4930        assert_eq!(withheld_outbox_refs(&client).len(), 1);
4931    }
4932
4933    /// Every outbox entry queued under the withheld-sources schema.
4934    ///
4935    /// The outbox stores sealed gift wraps, so the test reads the host's own
4936    /// statement of what it published rather than trying to decrypt them; the
4937    /// outbox reference proves the record was actually queued for the device.
4938    fn withheld_outbox_refs(client: &SarahConversationClient) -> Vec<String> {
4939        client
4940            .issue31_private_outbox
4941            .keys()
4942            .filter(|outbox_ref| outbox_ref.starts_with(crate::ISSUE31_WITHHELD_SOURCES_SCHEMA))
4943            .cloned()
4944            .collect()
4945    }
4946
4947    fn withheld_substance(
4948        client: &SarahConversationClient,
4949        grant_ref: &str,
4950        generation: u64,
4951    ) -> Option<(String, Vec<Issue31WithheldSourceCount>)> {
4952        client
4953            .issue31_withheld_emissions
4954            .get(&format!("{grant_ref}:{generation}"))
4955            .cloned()
4956    }
4957
4958    // -----------------------------------------------------------------
4959    // omega#49: the omega#47 documents actually leaving the host.
4960    //
4961    // `full_auto_ui` built both documents from live host state and nothing
4962    // ever published them, so a paired phone rendered `no_host_projection` on
4963    // every device. These cover the pump half: who the records are addressed
4964    // to, what binding they carry, and when they are NOT sent.
4965    // -----------------------------------------------------------------
4966
4967    fn adjunct_outbox_refs(client: &SarahConversationClient, schema: &str) -> Vec<String> {
4968        client
4969            .issue31_private_outbox
4970            .keys()
4971            .filter(|outbox_ref| outbox_ref.starts_with(schema))
4972            .cloned()
4973            .collect()
4974    }
4975
4976    /// A reading the pump can publish, carrying no delivery claim of its own.
4977    fn host_documents(host_ref: &str, snapshot_ref: &str) -> Issue31HostProjectionDocuments {
4978        Issue31HostProjectionDocuments {
4979            host: json!({
4980                "schema": ISSUE31_HOST_ADJUNCT_SCHEMA,
4981                "hostRef": host_ref,
4982                "snapshotRef": snapshot_ref,
4983                "generatedAtMs": 1_784_894_400_000_u64,
4984                "projections": [],
4985            }),
4986            detail: json!({
4987                "schema": ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
4988                "hostRef": host_ref,
4989                "snapshotRef": snapshot_ref,
4990                "generatedAtMs": 1_784_894_400_000_u64,
4991                "runs": [],
4992            }),
4993        }
4994    }
4995
4996    fn paired_adjunct_client(
4997        source: Option<Issue31HostProjectionSource>,
4998    ) -> (SarahConversationClient, Issue31HostController, String) {
4999        let signer = SigningIdentity::generate();
5000        let mut config = SarahConversationConfig::mock_fixture();
5001        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
5002        let owner_public_key_hex = signer.public_key_hex.clone();
5003        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
5004        let conversation_ref = config.conversation_ref();
5005        let mut client =
5006            SarahConversationClient::with_relay(config, Box::new(MockRelayAdapter::new()), signer);
5007        if let Some(source) = source {
5008            client.set_issue31_host_projection_source(source);
5009        }
5010        let device_public_key_hex = "2".repeat(64);
5011        let controller = pair_issue31_device(
5012            Issue31HostConfiguration {
5013                host_ref: "omega.host.local".into(),
5014                host_public_key_hex: owner_public_key_hex,
5015                sarah_public_key_hex,
5016                conversation: conversation_ref,
5017                display_name: "Omega host".into(),
5018                relay_urls: vec!["wss://relay.example.com".into()],
5019                generation: 1,
5020            },
5021            &device_public_key_hex,
5022        );
5023        (client, controller, device_public_key_hex)
5024    }
5025
5026    /// The gap itself: both omega#47 documents must reach every admitted
5027    /// device, addressed to that device and to the grant it holds.
5028    #[test]
5029    fn the_host_snapshot_and_its_detail_are_addressed_to_each_admitted_device() {
5030        let source: Issue31HostProjectionSource = Arc::new(|request| {
5031            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.aa")))
5032        });
5033        let (mut client, controller, device_public_key_hex) =
5034            paired_adjunct_client(Some(source));
5035        let grant = controller.active_grants(200).expect("grants")[0].clone();
5036        client.ensure_connected().expect("connect the mock relay");
5037        client
5038            .publish_issue31_host_adjuncts(&controller, 200)
5039            .expect("the pump publishes both documents");
5040
5041        assert_eq!(
5042            adjunct_outbox_refs(&client, ISSUE31_HOST_ADJUNCT_SCHEMA).len(),
5043            1,
5044        );
5045        assert_eq!(
5046            adjunct_outbox_refs(&client, ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA).len(),
5047            1,
5048        );
5049        assert!(
5050            client
5051                .issue31_host_adjunct_emissions
5052                .contains_key(&format!("{}:{}", grant.grant_ref, grant.generation)),
5053            "the pump must remember what it sent, or it resends forever",
5054        );
5055
5056        // The binding the device checks the envelope against.
5057        let addressed = SarahConversationClient::address_issue31_adjunct(
5058            &host_documents("omega.host.local", "snapshot.omega.issue31.aa").host,
5059            ISSUE31_HOST_ADJUNCT_SCHEMA,
5060            ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
5061            &grant,
5062        )
5063        .expect("the snapshot is addressable to this grant");
5064        assert_eq!(
5065            addressed.get("recordType").and_then(Value::as_str),
5066            Some(ISSUE31_HOST_ADJUNCT_RECORD_TYPE),
5067        );
5068        assert_eq!(
5069            addressed.get("devicePublicKeyHex").and_then(Value::as_str),
5070            Some(device_public_key_hex.as_str()),
5071        );
5072        assert_eq!(
5073            addressed.get("grantRef").and_then(Value::as_str),
5074            Some(grant.grant_ref.as_str()),
5075        );
5076        assert_eq!(
5077            addressed.get("hostPublicKeyHex").and_then(Value::as_str),
5078            Some(grant.host_public_key_hex.as_str()),
5079        );
5080    }
5081
5082    /// A pass that observed the same world must not re-send the snapshot. The
5083    /// device would otherwise be handed an identical record forever, and every
5084    /// one of them would cost it a decrypt.
5085    #[test]
5086    fn an_unchanged_reading_is_not_republished() {
5087        let source: Issue31HostProjectionSource = Arc::new(|request| {
5088            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.aa")))
5089        });
5090        let (mut client, controller, _device) = paired_adjunct_client(Some(source));
5091        client.ensure_connected().expect("connect the mock relay");
5092        client
5093            .publish_issue31_host_adjuncts(&controller, 200)
5094            .expect("first pass");
5095        client.issue31_private_outbox.clear();
5096        client
5097            .publish_issue31_host_adjuncts(&controller, 260)
5098            .expect("second pass");
5099        assert!(
5100            client.issue31_private_outbox.is_empty(),
5101            "an unchanged reading must not be republished",
5102        );
5103
5104        // A changed reading is a different snapshot and does go out again.
5105        let changed: Issue31HostProjectionSource = Arc::new(|request| {
5106            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.bb")))
5107        });
5108        client.set_issue31_host_projection_source(changed);
5109        client
5110            .publish_issue31_host_adjuncts(&controller, 320)
5111            .expect("third pass");
5112        assert_eq!(
5113            adjunct_outbox_refs(&client, ISSUE31_HOST_ADJUNCT_SCHEMA).len(),
5114            1,
5115        );
5116    }
5117
5118    /// A host that is not observing its Full Auto state says nothing rather
5119    /// than publishing an empty snapshot. Silence and "I looked and found
5120    /// nothing" are different claims and the device renders them differently.
5121    #[test]
5122    fn a_host_with_no_reading_publishes_nothing_at_all() {
5123        let (mut client, controller, _device) = paired_adjunct_client(None);
5124        client.ensure_connected().expect("connect the mock relay");
5125        client
5126            .publish_issue31_host_adjuncts(&controller, 200)
5127            .expect("a host with no reading still completes its pass");
5128        assert!(client.issue31_private_outbox.is_empty());
5129
5130        let silent: Issue31HostProjectionSource = Arc::new(|_| Ok(None));
5131        client.set_issue31_host_projection_source(silent);
5132        client
5133            .publish_issue31_host_adjuncts(&controller, 200)
5134            .expect("an unobserved host still completes its pass");
5135        assert!(client.issue31_private_outbox.is_empty());
5136    }
5137
5138    /// The one substitution a signed seal cannot rule out. A snapshot labelled
5139    /// with another machine's host reference would be bound by the device to
5140    /// this pairing, so the pump refuses to address it at all.
5141    #[test]
5142    fn a_snapshot_naming_another_host_is_never_addressed_to_this_device() {
5143        let (mut client, controller, _device) = paired_adjunct_client(None);
5144        let grant = controller.active_grants(200).expect("grants")[0].clone();
5145        let foreign = host_documents("omega.host.some-other-machine", "snapshot.omega.issue31.aa");
5146        assert!(
5147            SarahConversationClient::address_issue31_adjunct(
5148                &foreign.host,
5149                ISSUE31_HOST_ADJUNCT_SCHEMA,
5150                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
5151                &grant,
5152            )
5153            .is_err(),
5154        );
5155
5156        // And a reading that arrives already claiming who may read it is
5157        // refused rather than silently overwritten.
5158        let mut presumptuous = host_documents("omega.host.local", "snapshot.omega.issue31.aa").host;
5159        presumptuous
5160            .as_object_mut()
5161            .expect("object")
5162            .insert("devicePublicKeyHex".into(), json!("3".repeat(64)));
5163        assert!(
5164            SarahConversationClient::address_issue31_adjunct(
5165                &presumptuous,
5166                ISSUE31_HOST_ADJUNCT_SCHEMA,
5167                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
5168                &grant,
5169            )
5170            .is_err(),
5171        );
5172
5173        let source: Issue31HostProjectionSource = Arc::new(|_| {
5174            Ok(Some(host_documents(
5175                "omega.host.some-other-machine",
5176                "snapshot.omega.issue31.aa",
5177            )))
5178        });
5179        client.set_issue31_host_projection_source(source);
5180        client.ensure_connected().expect("connect the mock relay");
5181        assert!(client.publish_issue31_host_adjuncts(&controller, 200).is_err());
5182        assert!(client.issue31_private_outbox.is_empty());
5183    }
5184
5185    /// The snapshot advertises the capabilities; the detail is what the owner
5186    /// opens. A detail bound to a different snapshot is one the phone refuses
5187    /// as `snapshot_mismatch`, so publishing it would publish a refusal.
5188    #[test]
5189    fn a_detail_not_bound_to_the_snapshot_beside_it_is_never_sent() {
5190        let source: Issue31HostProjectionSource = Arc::new(|request| {
5191            let mut documents = host_documents(request.host_ref, "snapshot.omega.issue31.aa");
5192            documents
5193                .detail
5194                .as_object_mut()
5195                .expect("object")
5196                .insert("snapshotRef".into(), json!("snapshot.omega.issue31.bb"));
5197            Ok(Some(documents))
5198        });
5199        let (mut client, controller, _device) = paired_adjunct_client(Some(source));
5200        client.ensure_connected().expect("connect the mock relay");
5201        assert!(client.publish_issue31_host_adjuncts(&controller, 200).is_err());
5202    }
5203
5204    /// Pair one device with the host so the projection pass has somewhere to
5205    /// send a coverage statement.
5206    fn pair_issue31_device(
5207        host_configuration: Issue31HostConfiguration,
5208        device_public_key_hex: &str,
5209    ) -> Issue31HostController {
5210        let host_ref = host_configuration.host_ref.clone();
5211        let host_public_key_hex = host_configuration.host_public_key_hex.clone();
5212        let mut controller =
5213            Issue31HostController::new(host_configuration).expect("host controller");
5214        controller
5215            .set_admitted_device_policy(
5216                vec![device_public_key_hex.to_string()],
5217                vec![crate::Issue31PairingScope::ObserveIssue31],
5218            )
5219            .expect("admit the device");
5220        let challenge = controller
5221            .handle_pairing_event(
5222                Issue31PairingEvent {
5223                    event_id: "a".repeat(64),
5224                    record: Issue31PairingRecord::PairingRequest {
5225                        schema: crate::ISSUE31_PAIRING_SCHEMA.into(),
5226                        host_ref: host_ref.clone(),
5227                        host_public_key_hex: host_public_key_hex.clone(),
5228                        device_public_key_hex: device_public_key_hex.to_string(),
5229                        issued_at: 100,
5230                        pairing_request_ref: "pairing_request.coverage".into(),
5231                        requested_scopes: vec![crate::Issue31PairingScope::ObserveIssue31],
5232                        expires_at: 100_000,
5233                    },
5234                },
5235                100,
5236            )
5237            .expect("pairing request")
5238            .expect("pairing challenge");
5239        let Issue31PairingRecord::PairingChallenge {
5240            challenge: challenge_value,
5241            ..
5242        } = &challenge
5243        else {
5244            panic!("expected a pairing challenge");
5245        };
5246        let challenge_value = challenge_value.clone();
5247        controller
5248            .record_emitted_pairing("b".repeat(64), challenge)
5249            .expect("record the challenge");
5250        let grant = controller
5251            .handle_pairing_event(
5252                Issue31PairingEvent {
5253                    event_id: "c".repeat(64),
5254                    record: Issue31PairingRecord::PairingResponse {
5255                        schema: crate::ISSUE31_PAIRING_SCHEMA.into(),
5256                        host_ref,
5257                        host_public_key_hex,
5258                        device_public_key_hex: device_public_key_hex.to_string(),
5259                        issued_at: 110,
5260                        pairing_response_ref: "pairing_response.coverage".into(),
5261                        pairing_challenge_event_id: "b".repeat(64),
5262                        challenge: challenge_value,
5263                        expires_at: 100_000,
5264                    },
5265                },
5266                110,
5267            )
5268            .expect("pairing response")
5269            .expect("scoped grant");
5270        controller
5271            .record_emitted_pairing("d".repeat(64), grant)
5272            .expect("record the grant");
5273        controller
5274    }
5275
5276    /// Falsification, drop path one. A quarantined source is removed from the
5277    /// owner's view by a host-local map whose count never left the host. The
5278    /// device must be told an exact number and why, and the statement must go
5279    /// back to `complete` for a pass that withholds nothing.
5280    #[test]
5281    fn a_quarantined_source_is_reported_to_the_device_as_an_exact_withheld_count() {
5282        let signer = SigningIdentity::generate();
5283        let mut config = SarahConversationConfig::mock_fixture();
5284        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
5285        let owner_public_key_hex = signer.public_key_hex.clone();
5286        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
5287        let conversation_ref = config.conversation_ref();
5288        let mut relay = MockRelayAdapter::new();
5289        for (index, (event_id, created_at, text)) in [
5290            ("1".repeat(64), 10_u64, "readable"),
5291            // Inside every record-level bound and outside the projection body
5292            // contract, so the host quarantines it.
5293            ("2".repeat(64), 11, ""),
5294        ]
5295        .into_iter()
5296        .enumerate()
5297        {
5298            relay.seed_event(StoredConversationEvent {
5299                event_id,
5300                kind: crate::ISSUE31_PRIVATE_RUMOR_KIND,
5301                pubkey: owner_public_key_hex.clone(),
5302                created_at,
5303                conversation_ref: conversation_ref.clone(),
5304                content_summary: text.into(),
5305                tags: Vec::new(),
5306                record_kind: "message".into(),
5307                store_index: index,
5308            });
5309        }
5310        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
5311        let device_public_key_hex = "2".repeat(64);
5312        let mut controller = pair_issue31_device(
5313            Issue31HostConfiguration {
5314                host_ref: "omega.host.local".into(),
5315                host_public_key_hex: owner_public_key_hex,
5316                sarah_public_key_hex,
5317                conversation: conversation_ref,
5318                display_name: "Omega host".into(),
5319                relay_urls: vec!["wss://relay.example.com".into()],
5320                generation: 1,
5321            },
5322            &device_public_key_hex,
5323        );
5324        let grant_ref = controller.active_grants(200).expect("grants")[0]
5325            .grant_ref
5326            .clone();
5327
5328        client.ensure_connected().expect("connect the mock relay");
5329        client
5330            .project_issue31_sources(&mut controller, 200)
5331            .expect("the pass survives a quarantined source");
5332
5333        let (coverage, withheld) =
5334            withheld_substance(&client, &grant_ref, 1).expect("a coverage statement was published");
5335        assert_eq!(coverage, crate::ISSUE31_WITHHELD_COVERAGE_PARTIAL);
5336        assert_eq!(
5337            withheld,
5338            vec![Issue31WithheldSourceCount {
5339                cause: Issue31WithheldCause::Quarantined,
5340                count: 1,
5341                exact: true,
5342                reason_ref: ISSUE31_PROJECTION_SOURCE_QUARANTINE_REASON.into(),
5343            }]
5344        );
5345        assert_eq!(withheld_outbox_refs(&client).len(), 1);
5346
5347        // Restore: a host with nothing quarantined states completeness rather
5348        // than staying quiet, because silence would have to read as unknown.
5349        let clean_signer = SigningIdentity::generate();
5350        let mut clean_config = SarahConversationConfig::mock_fixture();
5351        clean_config.identity.owner_public_key_hex = clean_signer.public_key_hex.clone();
5352        let clean_owner = clean_signer.public_key_hex.clone();
5353        let clean_sarah = clean_config.identity.sarah_public_key_hex.clone();
5354        let clean_conversation = clean_config.conversation_ref();
5355        let mut clean_relay = MockRelayAdapter::new();
5356        clean_relay.seed_event(StoredConversationEvent {
5357            event_id: "1".repeat(64),
5358            kind: crate::ISSUE31_PRIVATE_RUMOR_KIND,
5359            pubkey: clean_owner.clone(),
5360            created_at: 10,
5361            conversation_ref: clean_conversation.clone(),
5362            content_summary: "readable".into(),
5363            tags: Vec::new(),
5364            record_kind: "message".into(),
5365            store_index: 0,
5366        });
5367        let mut clean_client =
5368            SarahConversationClient::with_relay(clean_config, Box::new(clean_relay), clean_signer);
5369        let mut clean_controller = pair_issue31_device(
5370            Issue31HostConfiguration {
5371                host_ref: "omega.host.local".into(),
5372                host_public_key_hex: clean_owner,
5373                sarah_public_key_hex: clean_sarah,
5374                conversation: clean_conversation,
5375                display_name: "Omega host".into(),
5376                relay_urls: vec!["wss://relay.example.com".into()],
5377                generation: 1,
5378            },
5379            &device_public_key_hex,
5380        );
5381        clean_client
5382            .ensure_connected()
5383            .expect("connect the mock relay");
5384        clean_client
5385            .project_issue31_sources(&mut clean_controller, 200)
5386            .expect("a clean pass");
5387        assert_eq!(
5388            withheld_substance(&clean_client, &grant_ref, 1),
5389            Some((crate::ISSUE31_WITHHELD_COVERAGE_COMPLETE.to_string(), vec![]))
5390        );
5391    }
5392
5393    /// Falsification, drop path two. The bounded projection scan stops after
5394    /// eight pages and, before this change, said so only in the host's own
5395    /// `last_gap_state`. The device must see an inexact count, and must see it
5396    /// clear once the scan catches up.
5397    #[test]
5398    fn the_projection_scan_bound_is_reported_to_the_device_and_clears_when_it_catches_up() {
5399        let signer = SigningIdentity::generate();
5400        let mut config = SarahConversationConfig::mock_fixture();
5401        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
5402        let owner_public_key_hex = signer.public_key_hex.clone();
5403        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
5404        let conversation_ref = config.conversation_ref();
5405        let mut relay = MockRelayAdapter::new();
5406        // One page past the eight-page bound. The kind is outside the
5407        // projection set, so this measures the scan bound itself rather than
5408        // the cost of projecting five hundred sources.
5409        let pages_past_the_bound = 8 * MAX_PAGE_LIMIT + 1;
5410        for index in 0..pages_past_the_bound {
5411            relay.seed_event(StoredConversationEvent {
5412                event_id: format!("{index:064x}"),
5413                kind: 1,
5414                pubkey: owner_public_key_hex.clone(),
5415                created_at: 10 + index as u64,
5416                conversation_ref: conversation_ref.clone(),
5417                content_summary: "unrelated".into(),
5418                tags: Vec::new(),
5419                record_kind: "note".into(),
5420                store_index: index,
5421            });
5422        }
5423        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
5424        let device_public_key_hex = "2".repeat(64);
5425        let mut controller = pair_issue31_device(
5426            Issue31HostConfiguration {
5427                host_ref: "omega.host.local".into(),
5428                host_public_key_hex: owner_public_key_hex,
5429                sarah_public_key_hex,
5430                conversation: conversation_ref,
5431                display_name: "Omega host".into(),
5432                relay_urls: vec!["wss://relay.example.com".into()],
5433                generation: 1,
5434            },
5435            &device_public_key_hex,
5436        );
5437        let grant_ref = controller.active_grants(200).expect("grants")[0]
5438            .grant_ref
5439            .clone();
5440
5441        client.ensure_connected().expect("connect the mock relay");
5442        client
5443            .project_issue31_sources(&mut controller, 200)
5444            .expect("the bounded pass");
5445
5446        let (coverage, withheld) =
5447            withheld_substance(&client, &grant_ref, 1).expect("a coverage statement was published");
5448        assert_eq!(coverage, crate::ISSUE31_WITHHELD_COVERAGE_PARTIAL);
5449        assert_eq!(
5450            withheld,
5451            vec![Issue31WithheldSourceCount {
5452                cause: Issue31WithheldCause::ScanBound,
5453                count: 1,
5454                exact: false,
5455                reason_ref: ISSUE31_PROJECTION_SCAN_BOUND_REASON.into(),
5456            }]
5457        );
5458        assert_eq!(client.last_gap_state, GapState::Possible);
5459        let after_bounded_pass = withheld_outbox_refs(&client);
5460        assert_eq!(after_bounded_pass.len(), 1);
5461
5462        // The cursor advanced, so the next pass reaches the end and the device
5463        // is told so. A signal that could only ever get worse would be a worse
5464        // lie than none.
5465        client
5466            .project_issue31_sources(&mut controller, 300)
5467            .expect("the catching-up pass");
5468        assert_eq!(
5469            withheld_substance(&client, &grant_ref, 1),
5470            Some((crate::ISSUE31_WITHHELD_COVERAGE_COMPLETE.to_string(), vec![]))
5471        );
5472        let after_catching_up = withheld_outbox_refs(&client);
5473        assert_eq!(after_catching_up.len(), 2);
5474        assert!(after_catching_up[0] != after_catching_up[1]);
5475    }
5476
5477    /// The host re-runs its projection pass continuously. A statement that has
5478    /// not changed must not be republished with only a new timestamp, or the
5479    /// device's own store fills with restatements of one fact.
5480    #[test]
5481    fn an_unchanged_coverage_statement_is_not_republished() {
5482        let signer = SigningIdentity::generate();
5483        let mut config = SarahConversationConfig::mock_fixture();
5484        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
5485        let owner_public_key_hex = signer.public_key_hex.clone();
5486        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
5487        let conversation_ref = config.conversation_ref();
5488        let mut relay = MockRelayAdapter::new();
5489        relay.seed_event(StoredConversationEvent {
5490            event_id: "1".repeat(64),
5491            kind: crate::ISSUE31_PRIVATE_RUMOR_KIND,
5492            pubkey: owner_public_key_hex.clone(),
5493            created_at: 10,
5494            conversation_ref: conversation_ref.clone(),
5495            content_summary: "readable".into(),
5496            tags: Vec::new(),
5497            record_kind: "message".into(),
5498            store_index: 0,
5499        });
5500        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
5501        let mut controller = pair_issue31_device(
5502            Issue31HostConfiguration {
5503                host_ref: "omega.host.local".into(),
5504                host_public_key_hex: owner_public_key_hex,
5505                sarah_public_key_hex,
5506                conversation: conversation_ref,
5507                display_name: "Omega host".into(),
5508                relay_urls: vec!["wss://relay.example.com".into()],
5509                generation: 1,
5510            },
5511            &"2".repeat(64),
5512        );
5513        client.ensure_connected().expect("connect the mock relay");
5514        client
5515            .project_issue31_sources(&mut controller, 200)
5516            .expect("the first pass");
5517        assert_eq!(withheld_outbox_refs(&client).len(), 1);
5518        client
5519            .project_issue31_sources(&mut controller, 400)
5520            .expect("the second pass, at a different clock");
5521        assert_eq!(withheld_outbox_refs(&client).len(), 1);
5522    }
5523
5524    #[test]
5525    fn snapshot_honors_independent_transcript_and_activity_windows() {
5526        let signer = SigningIdentity::generate();
5527        let mut config = SarahConversationConfig::mock_fixture();
5528        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
5529        let conversation_ref = config.conversation_ref();
5530        let mut relay = MockRelayAdapter::new();
5531        relay.seed_event(StoredConversationEvent {
5532            event_id: "1".repeat(64),
5533            kind: Kind::PrivateDirectMessage.as_u16(),
5534            pubkey: signer.public_key_hex.clone(),
5535            created_at: 1,
5536            conversation_ref: conversation_ref.clone(),
5537            content_summary: "first".into(),
5538            tags: Vec::new(),
5539            record_kind: "message".into(),
5540            store_index: 0,
5541        });
5542        relay.seed_event(StoredConversationEvent {
5543            event_id: "2".repeat(64),
5544            kind: SARAH_TURN_RECORD_KIND,
5545            pubkey: signer.public_key_hex.clone(),
5546            created_at: 2,
5547            conversation_ref: conversation_ref.clone(),
5548            content_summary: "activity".into(),
5549            tags: vec![
5550                vec!["entry".into(), "entry.test".into()],
5551                vec!["turn".into(), "turn.test".into()],
5552            ],
5553            record_kind: "activity".into(),
5554            store_index: 1,
5555        });
5556        relay.seed_event(StoredConversationEvent {
5557            event_id: "3".repeat(64),
5558            kind: Kind::PrivateDirectMessage.as_u16(),
5559            pubkey: signer.public_key_hex.clone(),
5560            created_at: 3,
5561            conversation_ref,
5562            content_summary: "second".into(),
5563            tags: Vec::new(),
5564            record_kind: "message".into(),
5565            store_index: 2,
5566        });
5567        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
5568        let snapshot = client
5569            .room_snapshot_with_cursors(
5570                Some(&format!("cursor.1.{}", "1".repeat(64))),
5571                Some(1),
5572                None,
5573                Some(1),
5574            )
5575            .expect("independent snapshot");
5576        assert_eq!(snapshot.transcript.entries[0].text, "second");
5577        assert_eq!(snapshot.activity.entries[0].entry, "entry.test");
5578        assert!(snapshot.transcript.next_cursor.is_none());
5579        assert!(snapshot.activity.next_cursor.is_none());
5580    }
5581
5582    #[test]
5583    fn snapshot_projects_bounded_confirmed_nostr_record_refs_without_content() {
5584        let config = SarahConversationConfig::mock_fixture();
5585        let conversation_ref = config.conversation_ref();
5586        let mut relay = MockRelayAdapter::new();
5587        relay.seed_event(StoredConversationEvent {
5588            event_id: "e".repeat(64),
5589            kind: 30_174,
5590            pubkey: "f".repeat(64),
5591            created_at: 100,
5592            conversation_ref,
5593            content_summary: "nip44:encrypted-memory-content".into(),
5594            tags: vec![vec!["d".into(), "a".repeat(64)]],
5595            record_kind: "memory".into(),
5596            store_index: 0,
5597        });
5598        let mut client = SarahConversationClient::with_relay(
5599            config,
5600            Box::new(relay),
5601            SigningIdentity::generate(),
5602        );
5603
5604        let snapshot = client
5605            .room_snapshot_with_record_cursor(None, Some(1), None, Some(1), None, Some(1))
5606            .expect("snapshot");
5607        assert_eq!(snapshot.nostr_records.entries.len(), 1);
5608        let record = &snapshot.nostr_records.entries[0];
5609        assert_eq!(record.event_id, "e".repeat(64));
5610        assert_eq!(record.kind, 30_174);
5611        assert_eq!(record.record_kind, "memory");
5612        assert_eq!(record.source, "confirmed_nostr");
5613        assert_eq!(record.author_fingerprint.len(), 16);
5614        let encoded = serde_json::to_string(&snapshot.nostr_records).expect("json");
5615        assert!(!encoded.contains("encrypted-memory-content"));
5616    }
5617
5618    #[test]
5619    fn nip42_auth_against_mock_relay_when_required() {
5620        let signer = SigningIdentity::generate();
5621        let challenge = "test-challenge-42";
5622        let mut client = SarahConversationClient::mock_with_nip42_auth(
5623            SarahConversationConfig::mock_fixture(),
5624            challenge,
5625            signer,
5626        );
5627        let boot = client.bootstrap().expect("bootstrap after NIP-42");
5628        assert!(boot.room_state.authenticated);
5629        assert_eq!(boot.room_state.transport, "nostr_relay");
5630        let sent = client
5631            .send_message("authenticated send", "idem.send.auth", 1)
5632            .expect("send after auth");
5633        assert!(sent.accepted);
5634    }
5635
5636    #[test]
5637    fn nip42_rejects_wrong_challenge() {
5638        let signer = SigningIdentity::generate();
5639        let mut relay = MockRelayAdapter::with_required_auth("expected-challenge");
5640        relay.connect().expect("connect");
5641        let auth = signer
5642            .sign_auth("wrong-challenge", "wss://relay.openagents.com")
5643            .expect("sign");
5644        let error = relay.authenticate(&auth).expect_err("must reject");
5645        assert!(error.to_string().contains("NIP-42"));
5646    }
5647
5648    #[test]
5649    fn frames_respect_64kib_and_redact_secrets() {
5650        let client = client();
5651        let result = json!({ "ok": true, "cursor": "cursor.0", "gapState": "none" });
5652        let line = client
5653            .encode_response_frame("1", 1, result)
5654            .expect("encode");
5655        assert!(line.len() < MAX_FRAME_BYTES);
5656        assert!(line.contains(PROTOCOL_SCHEMA));
5657
5658        let secretish = json!({ "text": "bearer super-secret-token" });
5659        let refused = client.encode_event_frame(1, SARAH_EVENT_ROOM_EVENT, secretish);
5660        assert!(refused.is_err());
5661    }
5662
5663    #[test]
5664    fn refuses_secret_shaped_outbound_message() {
5665        let mut client = client();
5666        client.bootstrap().expect("bootstrap");
5667        let error = client
5668            .send_message("Authorization: Bearer sk-test-123", "idem.send.secret", 1)
5669            .expect_err("secret");
5670        assert!(matches!(error, SarahConversationError::InvalidRequest(_)));
5671    }
5672
5673    #[test]
5674    fn client_collection_bounds_are_enforced_before_command_mutation() {
5675        let mut client = client();
5676        client.command_results = (0..MAX_COMMAND_RESULTS)
5677            .map(|index| {
5678                (
5679                    format!("idempotency.bound.{index}"),
5680                    ("fingerprint".into(), json!({ "accepted": true })),
5681                )
5682            })
5683            .collect();
5684        let params = json!({
5685            "text": "must not mutate",
5686            "idempotencyRef": "idempotency.bound.new",
5687            "expectedGeneration": 1,
5688        });
5689        let error = client
5690            .handle_request(SARAH_METHOD_SEND_MESSAGE, 1, Some(&params))
5691            .expect_err("command result bound");
5692        assert!(error.to_string().contains("bound"));
5693        assert_eq!(client.message_seq, 0);
5694        assert!(client.active_turn_ref.is_none());
5695        assert!(client.issue31_private_outbox.is_empty());
5696
5697        let state = client.current_room_state();
5698        for _ in 0..MAX_PENDING_EVENTS.saturating_add(32) {
5699            client.push_room_state_event(&state);
5700        }
5701        assert_eq!(client.pending_events.len(), MAX_PENDING_EVENTS);
5702    }
5703
5704    #[test]
5705    fn unbound_real_controller_never_reports_false_command_completion() {
5706        let mut client = client();
5707        client.run_state = "running".into();
5708        client.active_turn_ref = Some("turn.9".into());
5709        for action_ref in [
5710            "action.omega.full_auto.stop",
5711            "action.omega.full_auto.pause",
5712            "action.omega.full_auto.resume",
5713            "action.omega.interrupt_turn",
5714            "action.omega.send_message",
5715        ] {
5716            let execution = client.execute_issue31_action(
5717                action_ref,
5718                "arguments.omega.none",
5719                "idempotency.issue31.unbound",
5720            );
5721            assert_eq!(execution.status, Issue31CommandStatus::Unavailable);
5722            assert_eq!(
5723                execution.reason_ref.as_deref(),
5724                Some("reason.omega.controller_not_bound")
5725            );
5726        }
5727        assert_eq!(client.run_state, "running");
5728        assert_eq!(client.active_turn_ref.as_deref(), Some("turn.9"));
5729    }
5730
5731    // ---------------------------------------------------------------------
5732    // omega#91 provider connection handoffs
5733    // ---------------------------------------------------------------------
5734
5735    /// A paired device holding exactly the scope the phone already asks for.
5736    fn handoff_fixture_with(
5737        scopes: Vec<crate::Issue31PairingScope>,
5738    ) -> (
5739        SarahConversationClient,
5740        Issue31HostConfiguration,
5741        Issue31HostController,
5742        String,
5743        String,
5744    ) {
5745        let (configuration, controller, device_public_key_hex, grant_ref) =
5746            crate::issue31_nostr::paired_fixture(scopes);
5747        let mut config = SarahConversationConfig::mock_fixture();
5748        config.identity.owner_public_key_hex = configuration.host_public_key_hex.clone();
5749        let client = SarahConversationClient::with_relay(
5750            config,
5751            Box::new(MockRelayAdapter::new()),
5752            SigningIdentity::generate(),
5753        );
5754        (
5755            client,
5756            configuration,
5757            controller,
5758            device_public_key_hex,
5759            grant_ref,
5760        )
5761    }
5762
5763    fn handoff_fixture() -> (
5764        SarahConversationClient,
5765        Issue31HostConfiguration,
5766        Issue31HostController,
5767        String,
5768        String,
5769    ) {
5770        handoff_fixture_with(vec![crate::Issue31PairingScope::RequestProviderHandoff])
5771    }
5772
5773    fn handoff_intent(
5774        configuration: &Issue31HostConfiguration,
5775        device_public_key_hex: &str,
5776        grant_ref: &str,
5777        event_id: &str,
5778        idempotency_ref: &str,
5779        arguments_ref: &str,
5780    ) -> Issue31CommandEvent {
5781        Issue31CommandEvent {
5782            event_id: event_id.to_string(),
5783            record: Issue31CommandRecord::CommandIntent {
5784                schema: ISSUE31_COMMAND_SCHEMA.into(),
5785                host_ref: configuration.host_ref.clone(),
5786                host_public_key_hex: configuration.host_public_key_hex.clone(),
5787                device_public_key_hex: device_public_key_hex.to_string(),
5788                grant_ref: grant_ref.to_string(),
5789                action_ref: ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF.into(),
5790                idempotency_ref: idempotency_ref.to_string(),
5791                expected_generation: 1,
5792                arguments_ref: arguments_ref.to_string(),
5793                issued_at: 103,
5794                expires_at: 900,
5795            },
5796        }
5797    }
5798
5799    #[test]
5800    fn the_scope_the_phone_holds_now_produces_a_host_record() {
5801        // Before omega#91 this exact command reached `execute_issue31_action`
5802        // and came back `unavailable`/`controller_not_bound`, and the handoff
5803        // vector on the wire stayed empty forever.
5804        let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5805            handoff_fixture();
5806        let intent = handoff_intent(
5807            &configuration,
5808            &device_public_key_hex,
5809            &grant_ref,
5810            &"1".repeat(64),
5811            "idempotency.issue31.handoff:first",
5812            "arguments.omega.provider_handoff.anthropic",
5813        );
5814        let result = controller
5815            .handle_command_event(
5816                intent,
5817                104,
5818                |action_ref, arguments_ref, idempotency_ref| {
5819                    client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5820                },
5821            )
5822            .expect("the host answers an admitted handoff request")
5823            .expect("a command result");
5824        let Issue31CommandRecord::CommandResult {
5825            status,
5826            outcome_ref,
5827            reason_ref,
5828            ..
5829        } = &result
5830        else {
5831            panic!("expected a command result");
5832        };
5833        assert_eq!(*status, Issue31CommandStatus::Completed);
5834        assert_eq!(reason_ref.as_deref(), None);
5835        // The command completed by producing this record; the record itself is
5836        // not terminal. Reading one as the other is the mistake this asserts
5837        // against.
5838        assert_eq!(
5839            client.issue31_provider_handoff_refs(),
5840            vec![outcome_ref.clone()]
5841        );
5842        let record = client
5843            .issue31_provider_handoffs
5844            .get(outcome_ref)
5845            .expect("the outcome names the record the host made")
5846            .clone();
5847        assert_eq!(record.state, workroom_receipts::Issue31ProviderHandoffState::Requested);
5848        assert!(!record.is_terminal());
5849        assert!(record.requested_at_ms.is_some(), "the host stamped it");
5850        assert!(record.account_ref.is_none(), "nothing is bound yet");
5851        assert!(record.outcome_ref.is_none(), "no outcome is claimed yet");
5852    }
5853
5854    #[test]
5855    fn a_scope_denied_request_leaves_no_handoff_at_all() {
5856        // The failure-vs-never-started distinction, at the wire. A device
5857        // without the scope gets a refusal — and the host makes no record, so
5858        // the phone's handoff list is empty rather than showing a failed row
5859        // for something that never began.
5860        let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5861            handoff_fixture_with(vec![crate::Issue31PairingScope::ObserveIssue31]);
5862        let intent = handoff_intent(
5863            &configuration,
5864            &device_public_key_hex,
5865            &grant_ref,
5866            &"2".repeat(64),
5867            "idempotency.issue31.handoff:denied",
5868            "arguments.omega.provider_handoff.anthropic",
5869        );
5870        let result = controller
5871            .handle_command_event(
5872                intent,
5873                104,
5874                |action_ref, arguments_ref, idempotency_ref| {
5875                    client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5876                },
5877            )
5878            .expect("the controller answers")
5879            .expect("a command result");
5880        let Issue31CommandRecord::CommandResult { status, reason_ref, .. } = &result else {
5881            panic!("expected a command result");
5882        };
5883        assert_eq!(*status, Issue31CommandStatus::Refused);
5884        assert_eq!(reason_ref.as_deref(), Some("reason.omega.scope_denied"));
5885        assert!(
5886            client.issue31_provider_handoff_refs().is_empty(),
5887            "a request the host never admitted is not a handoff that failed",
5888        );
5889        assert!(client.issue31_projected_provider_handoffs(1_000_000).is_empty());
5890    }
5891
5892    #[test]
5893    fn a_handoff_request_naming_no_provider_is_refused_without_a_record() {
5894        let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5895            handoff_fixture();
5896        let intent = handoff_intent(
5897            &configuration,
5898            &device_public_key_hex,
5899            &grant_ref,
5900            &"3".repeat(64),
5901            "idempotency.issue31.handoff:bad_arguments",
5902            "arguments.omega.none",
5903        );
5904        let result = controller
5905            .handle_command_event(
5906                intent,
5907                104,
5908                |action_ref, arguments_ref, idempotency_ref| {
5909                    client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5910                },
5911            )
5912            .expect("the controller answers")
5913            .expect("a command result");
5914        let Issue31CommandRecord::CommandResult { status, reason_ref, .. } = &result else {
5915            panic!("expected a command result");
5916        };
5917        assert_eq!(*status, Issue31CommandStatus::Refused);
5918        assert_eq!(
5919            reason_ref.as_deref(),
5920            Some("reason.omega.handoff_arguments_invalid")
5921        );
5922        assert!(client.issue31_provider_handoff_refs().is_empty());
5923    }
5924
5925    #[test]
5926    fn a_handoff_in_flight_survives_a_restart_and_is_settled_rather_than_lost() {
5927        let temporary = tempfile::tempdir().expect("tempdir");
5928        let state_path = temporary.path().join("owner").join("issue31-state.json");
5929        let (mut client, configuration, controller, _, _) = handoff_fixture();
5930        client.issue31_state_path = Some(state_path.clone());
5931        let opened = client
5932            .issue31_provider_handoffs
5933            .open(
5934                "arguments.omega.provider_handoff.anthropic",
5935                "idempotency.issue31.handoff:restart",
5936                1_785_000_000_000,
5937            )
5938            .expect("the host opens a handoff");
5939        client
5940            .persist_issue31_host_state_with_controller(&controller)
5941            .expect("the ledger is committed with the rest of host state");
5942
5943        // Exactly what a restarted process reads back off disk.
5944        let reloaded = load_issue31_host_state(&state_path, &configuration)
5945            .expect("load")
5946            .expect("persisted state");
5947        let mut ledger = reloaded.provider_handoffs;
5948        assert_eq!(
5949            ledger.get(&opened.handoff_ref).expect("the row survived"),
5950            &opened,
5951            "a handoff in flight is not lost across a restart",
5952        );
5953
5954        // And a restart resolves it rather than leaving the phone with a
5955        // request that neither resolves nor fails.
5956        assert_eq!(ledger.adopt_after_restart(), 1);
5957        let settled = ledger.get(&opened.handoff_ref).expect("row").clone();
5958        assert_eq!(
5959            settled.state,
5960            workroom_receipts::Issue31ProviderHandoffState::Failed
5961        );
5962        assert_eq!(
5963            settled.reason_class.as_deref(),
5964            Some(crate::issue31_provider_handoff::ISSUE31_HANDOFF_REASON_HOST_RESTARTED)
5965        );
5966        assert!(settled.is_terminal());
5967    }
5968
5969    #[test]
5970    fn the_pump_advances_a_handoff_against_the_hosts_own_roster() {
5971        let (mut client, _, _, _, _) = handoff_fixture();
5972        let opened = client
5973            .issue31_provider_handoffs
5974            .open(
5975                "arguments.omega.provider_handoff.anthropic",
5976                "idempotency.issue31.handoff:roster",
5977                1_000,
5978            )
5979            .expect("open");
5980        client.set_issue31_provider_roster_source(Arc::new(|| {
5981            Some(vec![Issue31ProviderRosterAccount {
5982                account_ref: "account.claude.1".into(),
5983                provider: "anthropic".into(),
5984                lane_ref: "lane.claude-local".into(),
5985                readiness: "ready".into(),
5986            }])
5987        }));
5988        client.advance_issue31_provider_handoffs(2);
5989        assert_eq!(
5990            client
5991                .issue31_provider_handoffs
5992                .get(&opened.handoff_ref)
5993                .expect("row")
5994                .account_ref
5995                .as_deref(),
5996            Some("account.claude.1"),
5997        );
5998        client.advance_issue31_provider_handoffs(3);
5999        assert_eq!(
6000            client
6001                .issue31_provider_handoffs
6002                .get(&opened.handoff_ref)
6003                .expect("row")
6004                .state,
6005            workroom_receipts::Issue31ProviderHandoffState::Completed,
6006        );
6007    }
6008
6009    #[test]
6010    fn a_host_that_never_read_its_roster_binds_nothing() {
6011        let (mut client, _, _, _, _) = handoff_fixture();
6012        let opened = client
6013            .issue31_provider_handoffs
6014            .open(
6015                "arguments.omega.provider_handoff.anthropic",
6016                "idempotency.issue31.handoff:unread",
6017                1_000,
6018            )
6019            .expect("open");
6020        client.set_issue31_provider_roster_source(Arc::new(|| None));
6021        client.advance_issue31_provider_handoffs(2);
6022        assert_eq!(
6023            client
6024                .issue31_provider_handoffs
6025                .get(&opened.handoff_ref)
6026                .expect("row")
6027                .state,
6028            workroom_receipts::Issue31ProviderHandoffState::Requested,
6029        );
6030    }
6031
6032    #[test]
6033    fn no_khala_sync_client_on_sarah_lane() {
6034        assert!(asserts_no_khala_sync_client());
6035    }
6036
6037    #[test]
6038    fn handle_request_covers_all_methods() {
6039        let mut client = client();
6040        for method in SARAH_FRAMED_METHODS {
6041            if matches!(
6042                *method,
6043                SARAH_METHOD_DEVICE_GRANTS
6044                    | SARAH_METHOD_RENEW_DEVICE_GRANT
6045                    | SARAH_METHOD_REVOKE_DEVICE_GRANT
6046                    | SARAH_METHOD_READMIT_DEVICE
6047            ) {
6048                continue;
6049            }
6050            let params = match *method {
6051                SARAH_METHOD_SEND_MESSAGE => Some(json!({
6052                    "text": "hello from test",
6053                    "idempotencyRef": "idem.handle.send",
6054                    "expectedGeneration": 1,
6055                })),
6056                SARAH_METHOD_INTERRUPT_TURN => {
6057                    client
6058                        .send_message("prep", "idem.prep", 1)
6059                        .expect("prepare active turn");
6060                    Some(json!({
6061                        "turnRef": "turn.1",
6062                        "idempotencyRef": "idem.handle.interrupt",
6063                        "expectedGeneration": 1,
6064                    }))
6065                }
6066                SARAH_METHOD_ROOM_SNAPSHOT => Some(json!({ "limit": 5 })),
6067                _ => None,
6068            };
6069            client
6070                .handle_request(method, 1, params.as_ref())
6071                .unwrap_or_else(|error| panic!("{method} failed: {error}"));
6072        }
6073    }
6074
6075    struct OneHealthyOneDownRelay {
6076        state_path: PathBuf,
6077        query_count: Arc<AtomicUsize>,
6078        connected: bool,
6079        acknowledged_event_id: Option<String>,
6080    }
6081
6082    impl RelayTransport for OneHealthyOneDownRelay {
6083        fn label(&self) -> &str {
6084            "wss://healthy.example"
6085        }
6086
6087        fn connection_state(&self) -> ConnectionState {
6088            if self.connected {
6089                ConnectionState::Degraded
6090            } else {
6091                ConnectionState::Disconnected
6092            }
6093        }
6094
6095        fn is_authenticated(&self) -> bool {
6096            true
6097        }
6098
6099        fn connect(&mut self) -> Result<(), SarahConversationError> {
6100            self.connected = true;
6101            Ok(())
6102        }
6103
6104        fn auth_challenge(&self) -> Option<RelayAuthChallenge> {
6105            None
6106        }
6107
6108        fn authenticate(&mut self, _auth_event: &Event) -> Result<(), SarahConversationError> {
6109            Ok(())
6110        }
6111
6112        fn publish(&mut self, event: &Event) -> Result<(), SarahConversationError> {
6113            let persisted = fs::read_to_string(&self.state_path)
6114                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
6115            if !persisted.contains(&event.id.to_hex()) {
6116                return Err(SarahConversationError::Internal(
6117                    "signed outbox was not durable before publish".into(),
6118                ));
6119            }
6120            self.acknowledged_event_id = Some(event.id.to_hex());
6121            Ok(())
6122        }
6123
6124        fn publication_complete(&mut self, _event_id: &str) -> bool {
6125            false
6126        }
6127
6128        fn acknowledged_relays(&self, event_id: &str) -> Vec<String> {
6129            if self.acknowledged_event_id.as_deref() == Some(event_id) {
6130                vec!["wss://healthy.example".into()]
6131            } else {
6132                Vec::new()
6133            }
6134        }
6135
6136        fn query(
6137            &mut self,
6138            _conversation_ref: &str,
6139            _after_cursor: Option<&str>,
6140            _limit: usize,
6141        ) -> Result<QueryPage, SarahConversationError> {
6142            self.query_count.fetch_add(1, Ordering::SeqCst);
6143            Ok(QueryPage {
6144                events: Vec::new(),
6145                next_cursor: None,
6146                gap_state: GapState::Possible,
6147            })
6148        }
6149
6150        fn last_event_id(&self) -> Option<String> {
6151            self.acknowledged_event_id.clone()
6152        }
6153
6154        fn gap_state(&self) -> GapState {
6155            GapState::Possible
6156        }
6157
6158        fn connected_relays(&self) -> Vec<String> {
6159            vec!["wss://healthy.example".into()]
6160        }
6161
6162        fn requires_private_messages(&self) -> bool {
6163            true
6164        }
6165    }
6166
6167    #[test]
6168    fn partial_relay_ack_is_durable_and_does_not_block_querying() {
6169        let temporary = tempfile::tempdir().expect("tempdir");
6170        let state_path = temporary.path().join("owner").join("issue31-state.json");
6171        let query_count = Arc::new(AtomicUsize::new(0));
6172        let signer = SigningIdentity::generate();
6173        let mut config = SarahConversationConfig::mock_fixture();
6174        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
6175        let relay_urls = vec![
6176            "wss://healthy.example".to_string(),
6177            "wss://down.example".to_string(),
6178        ];
6179        let controller = Issue31HostController::new(Issue31HostConfiguration {
6180            host_ref: "omega.host.local".into(),
6181            host_public_key_hex: signer.public_key_hex.clone(),
6182            sarah_public_key_hex: config.identity.sarah_public_key_hex.clone(),
6183            conversation: config.conversation_ref(),
6184            display_name: "Local Omega".into(),
6185            relay_urls,
6186            generation: ISSUE31_NOSTR_HOST_GENERATION,
6187        })
6188        .expect("controller");
6189        let relay = OneHealthyOneDownRelay {
6190            state_path: state_path.clone(),
6191            query_count: query_count.clone(),
6192            connected: false,
6193            acknowledged_event_id: None,
6194        };
6195        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6196        client.issue31_host = Some(controller);
6197        client.issue31_state_path = Some(state_path.clone());
6198
6199        client.sync_issue31_host().expect("partial relay sync");
6200
6201        assert!(query_count.load(Ordering::SeqCst) > 0);
6202        let persisted: DurableIssue31HostState =
6203            serde_json::from_slice(&fs::read(&state_path).expect("read durable state"))
6204                .expect("decode durable state");
6205        assert!(persisted.discovery_event_json.is_some());
6206        assert_eq!(persisted.relay_acknowledgements.len(), 1);
6207        assert_eq!(
6208            client.current_room_state().connection,
6209            ConnectionState::Degraded
6210        );
6211        assert_eq!(
6212            client.current_room_state().connected_relays,
6213            vec!["wss://healthy.example"]
6214        );
6215    }
6216
6217    #[test]
6218    fn normal_commands_commit_exact_private_outbox_and_result_before_publish() {
6219        let temporary = tempfile::tempdir().expect("tempdir");
6220        let state_path = temporary.path().join("owner").join("issue31-state.json");
6221        let signer = SigningIdentity::generate();
6222        let mut config = SarahConversationConfig::mock_fixture();
6223        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
6224        let sarah_keys = Keys::generate();
6225        config.identity.sarah_public_key_hex = sarah_keys.public_key().to_hex();
6226        config.relay_url = Some("wss://healthy.example".into());
6227        let relay_urls = vec![
6228            "wss://healthy.example".to_string(),
6229            "wss://down.example".to_string(),
6230        ];
6231        let controller = Issue31HostController::new(Issue31HostConfiguration {
6232            host_ref: "omega.host.local".into(),
6233            host_public_key_hex: signer.public_key_hex.clone(),
6234            sarah_public_key_hex: config.identity.sarah_public_key_hex.clone(),
6235            conversation: config.conversation_ref(),
6236            display_name: "Local Omega".into(),
6237            relay_urls,
6238            generation: ISSUE31_NOSTR_HOST_GENERATION,
6239        })
6240        .expect("controller");
6241        let relay = OneHealthyOneDownRelay {
6242            state_path: state_path.clone(),
6243            query_count: Arc::new(AtomicUsize::new(0)),
6244            connected: false,
6245            acknowledged_event_id: None,
6246        };
6247        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6248        client.issue31_host = Some(controller);
6249        client.issue31_state_path = Some(state_path.clone());
6250
6251        let send_params = json!({
6252            "text": "durable hello",
6253            "idempotencyRef": "idempotency.normal.send",
6254            "expectedGeneration": 1,
6255        });
6256        let first = client
6257            .handle_request(SARAH_METHOD_SEND_MESSAGE, 1, Some(&send_params))
6258            .expect("send");
6259        let replay = client
6260            .handle_request(SARAH_METHOD_SEND_MESSAGE, 1, Some(&send_params))
6261            .expect("idempotent replay");
6262        assert_eq!(first, replay);
6263        assert_eq!(client.message_seq, 1);
6264
6265        let second_send_params = json!({
6266            "text": "durable hello",
6267            "idempotencyRef": "idempotency.normal.send.second",
6268            "expectedGeneration": 1,
6269        });
6270        let second = client
6271            .handle_request(SARAH_METHOD_SEND_MESSAGE, 1, Some(&second_send_params))
6272            .expect("distinct repeated send");
6273        assert_ne!(first.get("eventId"), second.get("eventId"));
6274        assert_eq!(client.message_seq, 2);
6275
6276        let persisted: DurableIssue31HostState =
6277            serde_json::from_slice(&fs::read(&state_path).expect("read durable state"))
6278                .expect("decode durable state");
6279        assert_eq!(persisted.active_turn_ref.as_deref(), Some("turn.2"));
6280        assert_eq!(persisted.run_state, "running");
6281        assert_eq!(persisted.message_seq, 2);
6282        assert!(
6283            persisted
6284                .command_results
6285                .contains_key("idempotency.normal.send")
6286        );
6287        assert!(
6288            persisted
6289                .command_results
6290                .contains_key("idempotency.normal.send.second")
6291        );
6292        assert_eq!(persisted.private_outbox.len(), 2);
6293        let rumor_ids = persisted
6294            .private_outbox
6295            .values()
6296            .map(|pending| pending.rumor_event_id.as_str())
6297            .collect::<std::collections::BTreeSet<_>>();
6298        assert_eq!(rumor_ids.len(), 2);
6299        assert_eq!(persisted.relay_acknowledgements.len(), 4);
6300        let serialized = fs::read_to_string(&state_path).expect("read serialized state");
6301        for pending in persisted.private_outbox.values() {
6302            for gift_wrap in &pending.gift_wrap_event_json {
6303                let event = Event::from_json(gift_wrap).expect("gift wrap");
6304                assert!(serialized.contains(&event.id.to_hex()));
6305            }
6306        }
6307        let mut unwrapped_turn_refs = std::collections::BTreeSet::new();
6308        for pending in persisted.private_outbox.values() {
6309            let mut found_sarah_copy = false;
6310            for gift_wrap in &pending.gift_wrap_event_json {
6311                let event = Event::from_json(gift_wrap).expect("gift wrap");
6312                match smol::block_on(nostr::nips::nip59::extract_rumor(&sarah_keys, &event)) {
6313                    Ok(unwrapped) => {
6314                        found_sarah_copy = true;
6315                        let turn_ref = unwrapped
6316                            .rumor
6317                            .tags
6318                            .iter()
6319                            .find_map(|tag| {
6320                                let tag = tag.as_slice();
6321                                (tag.first().map(String::as_str) == Some("turn"))
6322                                    .then(|| tag.get(1).cloned())
6323                                    .flatten()
6324                            })
6325                            .expect("turn tag in Sarah rumor");
6326                        unwrapped_turn_refs.insert(turn_ref);
6327                    }
6328                    // The other gift wrap is the owner's independently encrypted copy.
6329                    Err(_) => {}
6330                }
6331            }
6332            assert!(found_sarah_copy);
6333        }
6334        assert_eq!(
6335            unwrapped_turn_refs,
6336            std::collections::BTreeSet::from(["turn.1".to_string(), "turn.2".to_string()])
6337        );
6338
6339        let interrupt_params = json!({
6340            "turnRef": "turn.2",
6341            "idempotencyRef": "idempotency.normal.interrupt",
6342            "expectedGeneration": 1,
6343        });
6344        client
6345            .handle_request(SARAH_METHOD_INTERRUPT_TURN, 1, Some(&interrupt_params))
6346            .expect("interrupt");
6347        let persisted: DurableIssue31HostState =
6348            serde_json::from_slice(&fs::read(&state_path).expect("read durable state"))
6349                .expect("decode durable state");
6350        assert_eq!(persisted.run_state, "interrupt_pending");
6351        assert!(
6352            persisted
6353                .command_results
6354                .contains_key("idempotency.normal.interrupt")
6355        );
6356        assert_eq!(persisted.private_outbox.len(), 3);
6357    }
6358
6359    #[test]
6360    fn issue31_control_scan_persists_cursor_and_resumes_after_page_cap() {
6361        let temporary = tempfile::tempdir().expect("tempdir");
6362        let state_path = temporary.path().join("owner").join("issue31-state.json");
6363        let signer = SigningIdentity::generate();
6364        let mut config = SarahConversationConfig::mock_fixture();
6365        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
6366        let conversation_ref = config.conversation_ref();
6367        let mut relay = MockRelayAdapter::new();
6368        for index in 0..513 {
6369            relay.seed_event(StoredConversationEvent {
6370                event_id: format!("{index:064x}"),
6371                kind: 1,
6372                pubkey: signer.public_key_hex.clone(),
6373                created_at: 1_000 + index as u64,
6374                conversation_ref: conversation_ref.clone(),
6375                content_summary: "bounded non-control record".into(),
6376                tags: Vec::new(),
6377                record_kind: "message".into(),
6378                store_index: index,
6379            });
6380        }
6381        let controller = Issue31HostController::new(Issue31HostConfiguration {
6382            host_ref: "omega.host.local".into(),
6383            host_public_key_hex: signer.public_key_hex.clone(),
6384            sarah_public_key_hex: config.identity.sarah_public_key_hex.clone(),
6385            conversation: config.conversation_ref(),
6386            display_name: "Local Omega".into(),
6387            relay_urls: vec!["wss://relay.example.com".into()],
6388            generation: ISSUE31_NOSTR_HOST_GENERATION,
6389        })
6390        .expect("controller");
6391        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6392        client.issue31_host = Some(controller);
6393        client.issue31_state_path = Some(state_path.clone());
6394
6395        client.sync_issue31_host().expect("first bounded scan");
6396        assert_eq!(client.last_gap_state, GapState::Possible);
6397        let first_expected_cursor = format!("cursor.1511.{:064x}", 511);
6398        assert_eq!(
6399            client.issue31_control_cursor.as_deref(),
6400            Some(first_expected_cursor.as_str())
6401        );
6402        client.sync_issue31_host().expect("resume scan");
6403        let second_expected_cursor = format!("cursor.1512.{:064x}", 512);
6404        assert_eq!(
6405            client.issue31_control_cursor.as_deref(),
6406            Some(second_expected_cursor.as_str())
6407        );
6408        let persisted: DurableIssue31HostState =
6409            serde_json::from_slice(&fs::read(state_path).expect("read durable state"))
6410                .expect("decode durable state");
6411        assert_eq!(persisted.control_cursor, client.issue31_control_cursor);
6412    }
6413
6414    #[test]
6415    fn invalid_issue31_event_is_quarantined_without_starving_later_pairing() {
6416        let temporary = tempfile::tempdir().expect("tempdir");
6417        let state_path = temporary.path().join("owner").join("issue31-state.json");
6418        let signer = SigningIdentity::generate();
6419        let device = Keys::generate();
6420        let device_public_key_hex = device.public_key().to_hex();
6421        let sarah_public_key_hex = Keys::generate().public_key().to_hex();
6422        let mut config = SarahConversationConfig::mock_fixture();
6423        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
6424        config.identity.sarah_public_key_hex = sarah_public_key_hex.clone();
6425        let conversation_ref = config.conversation_ref();
6426        let now = unix_now();
6427        let expired = Issue31PairingRecord::PairingRequest {
6428            schema: ISSUE31_PAIRING_SCHEMA.into(),
6429            host_ref: "omega.host.local".into(),
6430            host_public_key_hex: signer.public_key_hex.clone(),
6431            device_public_key_hex: device_public_key_hex.clone(),
6432            issued_at: 100,
6433            pairing_request_ref: "pairing_request.device.expired".into(),
6434            requested_scopes: vec![crate::Issue31PairingScope::ObserveIssue31],
6435            expires_at: 200,
6436        };
6437        let valid = Issue31PairingRecord::PairingRequest {
6438            schema: ISSUE31_PAIRING_SCHEMA.into(),
6439            host_ref: "omega.host.local".into(),
6440            host_public_key_hex: signer.public_key_hex.clone(),
6441            device_public_key_hex: device_public_key_hex.clone(),
6442            issued_at: now,
6443            pairing_request_ref: "pairing_request.device.valid".into(),
6444            requested_scopes: vec![crate::Issue31PairingScope::ObserveIssue31],
6445            expires_at: now.saturating_add(600),
6446        };
6447        let mut relay = MockRelayAdapter::new();
6448        for (index, (event_id, record)) in [("a".repeat(64), expired), ("b".repeat(64), valid)]
6449            .into_iter()
6450            .enumerate()
6451        {
6452            relay.seed_event(StoredConversationEvent {
6453                event_id,
6454                kind: Kind::PrivateDirectMessage.as_u16(),
6455                pubkey: device_public_key_hex.clone(),
6456                created_at: now.saturating_add(index as u64),
6457                conversation_ref: conversation_ref.clone(),
6458                content_summary: serde_json::to_string(&record).expect("pairing json"),
6459                tags: Vec::new(),
6460                record_kind: "pairing".into(),
6461                store_index: index,
6462            });
6463        }
6464        let mut controller = Issue31HostController::new(Issue31HostConfiguration {
6465            host_ref: "omega.host.local".into(),
6466            host_public_key_hex: signer.public_key_hex.clone(),
6467            sarah_public_key_hex,
6468            conversation: config.conversation_ref(),
6469            display_name: "Local Omega".into(),
6470            relay_urls: vec!["wss://relay.example.com".into()],
6471            generation: ISSUE31_NOSTR_HOST_GENERATION,
6472        })
6473        .expect("controller");
6474        controller
6475            .set_admitted_device_policy(
6476                vec![device_public_key_hex],
6477                vec![crate::Issue31PairingScope::ObserveIssue31],
6478            )
6479            .expect("admit device");
6480        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6481        client.issue31_host = Some(controller);
6482        client.issue31_state_path = Some(state_path.clone());
6483
6484        client.sync_issue31_host().expect("quarantine and continue");
6485        assert_eq!(
6486            client
6487                .issue31_quarantined_events
6488                .get(&"a".repeat(64))
6489                .map(String::as_str),
6490            Some("reason.omega.pairing_rejected")
6491        );
6492        let controller = client.issue31_host.as_ref().expect("controller");
6493        assert!(controller.pairing_event_was_processed(&"b".repeat(64)));
6494        let persisted: DurableIssue31HostState =
6495            serde_json::from_slice(&fs::read(state_path).expect("read durable state"))
6496                .expect("decode durable state");
6497        assert_eq!(persisted.quarantined_events.len(), 1);
6498        assert!(persisted.control_cursor.is_some());
6499    }
6500
6501    #[test]
6502    fn inbound_pairing_is_retryable_when_atomic_state_and_outbox_commit_fails() {
6503        let temporary = tempfile::tempdir().expect("tempdir");
6504        let state_path = temporary.path().join("owner").join("issue31-state.json");
6505        let signer = SigningIdentity::generate();
6506        let device_public_key_hex = Keys::generate().public_key().to_hex();
6507        let sarah_public_key_hex = Keys::generate().public_key().to_hex();
6508        let mut config = SarahConversationConfig::mock_fixture();
6509        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
6510        config.identity.sarah_public_key_hex = sarah_public_key_hex.clone();
6511        let conversation_ref = config.conversation_ref();
6512        let now = unix_now();
6513        let inbound_event_id = "c".repeat(64);
6514        let request = Issue31PairingRecord::PairingRequest {
6515            schema: ISSUE31_PAIRING_SCHEMA.into(),
6516            host_ref: "omega.host.local".into(),
6517            host_public_key_hex: signer.public_key_hex.clone(),
6518            device_public_key_hex: device_public_key_hex.clone(),
6519            issued_at: now,
6520            pairing_request_ref: "pairing_request.device.retry".into(),
6521            requested_scopes: vec![crate::Issue31PairingScope::ObserveIssue31],
6522            expires_at: now.saturating_add(600),
6523        };
6524        let mut relay = MockRelayAdapter::new();
6525        relay.seed_event(StoredConversationEvent {
6526            event_id: inbound_event_id.clone(),
6527            kind: Kind::PrivateDirectMessage.as_u16(),
6528            pubkey: device_public_key_hex.clone(),
6529            created_at: now,
6530            conversation_ref,
6531            content_summary: serde_json::to_string(&request).expect("pairing json"),
6532            tags: Vec::new(),
6533            record_kind: "pairing".into(),
6534            store_index: 0,
6535        });
6536        let mut controller = Issue31HostController::new(Issue31HostConfiguration {
6537            host_ref: "omega.host.local".into(),
6538            host_public_key_hex: signer.public_key_hex.clone(),
6539            sarah_public_key_hex,
6540            conversation: config.conversation_ref(),
6541            display_name: "Local Omega".into(),
6542            relay_urls: vec!["wss://relay.example.com".into()],
6543            generation: ISSUE31_NOSTR_HOST_GENERATION,
6544        })
6545        .expect("controller");
6546        controller
6547            .set_admitted_device_policy(
6548                vec![device_public_key_hex],
6549                vec![crate::Issue31PairingScope::ObserveIssue31],
6550            )
6551            .expect("device policy");
6552        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6553        client.issue31_host = Some(controller);
6554        client.issue31_state_path = Some(state_path);
6555        client.issue31_discovery_generation = Some(ISSUE31_NOSTR_HOST_GENERATION);
6556        client.issue31_discovery_expires_at = Some(now.saturating_add(2 * 60 * 60));
6557        client.issue31_fail_commit_after.set(Some(1));
6558
6559        let error = client
6560            .sync_issue31_host()
6561            .expect_err("injected transaction failure");
6562        assert!(
6563            error
6564                .to_string()
6565                .contains("injected Issue 31 durable commit failure")
6566        );
6567        assert!(client.issue31_private_outbox.is_empty());
6568        assert!(
6569            !client
6570                .issue31_host
6571                .as_ref()
6572                .expect("controller")
6573                .pairing_event_was_processed(&inbound_event_id)
6574        );
6575
6576        client.sync_issue31_host().expect("retry pairing");
6577        assert!(
6578            client
6579                .issue31_host
6580                .as_ref()
6581                .expect("controller")
6582                .pairing_event_was_processed(&inbound_event_id)
6583        );
6584    }
6585
6586    #[test]
6587    fn durable_issue31_state_reuses_exact_outbox_and_refuses_revoked_replay_after_restart() {
6588        let temporary = tempfile::tempdir().expect("tempdir");
6589        let state_path = temporary.path().join("owner").join("issue31-state.json");
6590        let (configuration, controller, device_public_key_hex, grant_ref) =
6591            crate::issue31_nostr::restart_fixture();
6592        let signer = ConversationSigner::Keys(SigningIdentity::generate());
6593        let tags = vec![Tag::parse(["p", device_public_key_hex.as_str()]).expect("p tag")];
6594        let (rumor_event_id, gift_wraps) = signer
6595            .private_messages(
6596                "{\"schema\":\"openagents.omega.issue31.command.v1\"}",
6597                tags,
6598                std::slice::from_ref(&device_public_key_hex),
6599            )
6600            .expect("private outbox");
6601        let exact_event_json = gift_wraps[0].try_as_json().expect("event json");
6602        let state = DurableIssue31HostState {
6603            schema: ISSUE31_DURABLE_STATE_SCHEMA.into(),
6604            controller,
6605            discovery_generation: Some(1),
6606            discovery_expires_at: Some(10_000),
6607            discovery_event_json: None,
6608            private_outbox: BTreeMap::from([(
6609                "openagents.omega.issue31.command.v1.test".into(),
6610                DurableIssue31PrivatePublish {
6611                    rumor_event_id,
6612                    gift_wrap_event_json: vec![exact_event_json.clone()],
6613                },
6614            )]),
6615            relay_acknowledgements: BTreeMap::new(),
6616            control_cursor: Some(format!("cursor.10.{}", "f".repeat(64))),
6617            projection_cursor: Some(format!("cursor.11.{}", "e".repeat(64))),
6618            quarantined_events: BTreeMap::from([(
6619                "9".repeat(64),
6620                "reason.omega.invalid_pairing_record".into(),
6621            )]),
6622            host_adjunct_emissions: BTreeMap::new(),
6623            provider_handoffs: Issue31ProviderHandoffLedger::default(),
6624            command_results: BTreeMap::from([(
6625                "idempotency.restart.admin".into(),
6626                ("fingerprint".into(), json!({ "eventId": "1".repeat(64) })),
6627            )]),
6628            active_turn_ref: Some("turn.7".into()),
6629            run_state: "running".into(),
6630            message_seq: 7,
6631        };
6632        write_issue31_host_state(&state_path, &state).expect("write state");
6633        let serialized = fs::read_to_string(&state_path).expect("read state");
6634        assert!(!serialized.contains("nsec"));
6635        #[cfg(unix)]
6636        {
6637            use std::os::unix::fs::PermissionsExt as _;
6638            assert_eq!(
6639                fs::metadata(&state_path)
6640                    .expect("metadata")
6641                    .permissions()
6642                    .mode()
6643                    & 0o777,
6644                0o600
6645            );
6646        }
6647
6648        let loaded = load_issue31_host_state(&state_path, &configuration)
6649            .expect("load state")
6650            .expect("persisted state");
6651        assert_eq!(loaded.active_turn_ref.as_deref(), Some("turn.7"));
6652        assert_eq!(loaded.run_state, "running");
6653        assert_eq!(loaded.message_seq, 7);
6654        let runtime_outbox = durable_private_outbox_into_runtime(
6655            loaded
6656                .private_outbox
6657                .into_iter()
6658                .collect::<BTreeMap<_, _>>(),
6659        )
6660        .expect("runtime outbox");
6661        assert_eq!(
6662            runtime_outbox
6663                .values()
6664                .next()
6665                .expect("outbox item")
6666                .gift_wraps[0]
6667                .try_as_json()
6668                .expect("reloaded event json"),
6669            exact_event_json
6670        );
6671
6672        let mut controller = loaded.controller;
6673        let result = controller
6674            .handle_command_event(
6675                Issue31CommandEvent {
6676                    event_id: "0".repeat(64),
6677                    record: Issue31CommandRecord::CommandIntent {
6678                        schema: ISSUE31_COMMAND_SCHEMA.into(),
6679                        host_ref: configuration.host_ref,
6680                        host_public_key_hex: configuration.host_public_key_hex,
6681                        device_public_key_hex,
6682                        grant_ref,
6683                        action_ref: "action.omega.full_auto.stop".into(),
6684                        idempotency_ref: "idempotency.restart.revoked_replay".into(),
6685                        expected_generation: 1,
6686                        arguments_ref: "arguments.omega.none".into(),
6687                        issued_at: 106,
6688                        expires_at: 200,
6689                    },
6690                },
6691                107,
6692                |_, _, _| panic!("revoked device command executed"),
6693            )
6694            .expect("terminal refusal")
6695            .expect("result record");
6696        assert!(matches!(
6697            result,
6698            Issue31CommandRecord::CommandResult {
6699                status: Issue31CommandStatus::Refused,
6700                ..
6701            }
6702        ));
6703    }
6704
6705    // ---------------------------------------------------------------------
6706    // omega#49 device-proof host
6707    // ---------------------------------------------------------------------
6708
6709    /// Read a `Keys` from a 64-hex secret in the environment.
6710    #[cfg(test)]
6711    fn device_proof_keys(variable: &str) -> Keys {
6712        let secret = std::env::var(variable)
6713            .unwrap_or_else(|_| panic!("{variable} must be a 64-hex secret key"));
6714        Keys::parse(secret.trim()).unwrap_or_else(|error| panic!("{variable}: {error}"))
6715    }
6716
6717    /// Publish one event through a real adapter, meeting the NIP-42 challenge
6718    /// lazily exactly as `publish_with_auth` does.
6719    #[cfg(test)]
6720    /// Sarah's half of the conversation, shaped the way the host actually reads it.
6721    ///
6722    /// The host subscribes to gift wraps addressed to its own custody key and
6723    /// unwraps the kind-14 rumor inside into a `message` record — the identical
6724    /// path the owner's own sends take. A bare kind 14 published straight to
6725    /// the relay is never requested by any filter and never arrives, and a
6726    /// kind 44300 is a `Turn`, which must carry NIP-44 ciphertext of JSON and
6727    /// is not a conversation message at all.
6728    ///
6729    /// `conversation_tags` is the production tag builder, so the rumor names
6730    /// exactly the owner and Sarah and satisfies `require_conversation_recipients`
6731    /// rather than approximating it here.
6732    fn sarah_conversation_wrap(
6733        sarah_keys: &Keys,
6734        owner_public_key: &PublicKey,
6735        owner_public_key_hex: &str,
6736        conversation_ref: &str,
6737        text: &str,
6738    ) -> (nostr::Event, String) {
6739        let tags = conversation_tags(
6740            conversation_ref,
6741            owner_public_key_hex,
6742            &sarah_keys.public_key().to_hex(),
6743        )
6744        .expect("production conversation tags");
6745        let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, text)
6746            .tags(tags)
6747            .build(sarah_keys.public_key());
6748        rumor.ensure_id();
6749        // The wrap id is not the record id. The host stores the rumor under its
6750        // own id and projects that id to the device, so a harness that reports
6751        // the wrap id is naming an identifier nothing downstream ever uses.
6752        let rumor_id = rumor.id.expect("the rumor carries its id").to_hex();
6753        let wrap = smol::block_on(EventBuilder::gift_wrap(
6754            sarah_keys,
6755            owner_public_key,
6756            rumor,
6757            [],
6758        ))
6759        .expect("gift wrap Sarah's message to the host");
6760        (wrap, rumor_id)
6761    }
6762
6763    /// Publish, and survive a relay that closed an idle socket.
6764    ///
6765    /// The harness holds one Sarah connection for the life of the run and used
6766    /// to publish straight down it. A reply sent minutes after seeding arrived
6767    /// on a socket the relay had already reset, and the harness reported it as
6768    /// Sarah failing to answer — indistinguishable, from the outside, from the
6769    /// host never producing a reply. A one-message proof would have failed on
6770    /// transport before reaching any of the record contracts.
6771    fn device_proof_publish(
6772        relay: &mut crate::nostr_websocket_relay::WebSocketRelayAdapter,
6773        auth_url: &str,
6774        keys: &Keys,
6775        record: &Event,
6776    ) -> Result<(), SarahConversationError> {
6777        match device_proof_publish_authenticated(relay, auth_url, keys, record) {
6778            Ok(()) => Ok(()),
6779            Err(error) => {
6780                eprintln!("device-proof: sarah publish failed ({error}); reconnecting");
6781                relay.connect()?;
6782                // A new socket is a new NIP-42 session, so the auth path runs
6783                // again rather than assuming the old challenge still holds.
6784                device_proof_publish_authenticated(relay, auth_url, keys, record)
6785            }
6786        }
6787    }
6788
6789    fn device_proof_publish_authenticated(
6790        relay: &mut crate::nostr_websocket_relay::WebSocketRelayAdapter,
6791        auth_url: &str,
6792        keys: &Keys,
6793        record: &Event,
6794    ) -> Result<(), SarahConversationError> {
6795        match relay.publish(record) {
6796            Err(SarahConversationError::IdentityRequired) => {
6797                // Every step here returns rather than panics. A relay that
6798                // closed an idle socket fails *inside* this branch — `publish`
6799                // reports `IdentityRequired`, control comes here, and the AUTH
6800                // write hits the reset — so an `expect` on this path takes the
6801                // whole harness down before any retry wrapper can see it, and
6802                // reports a dead socket as Sarah failing to answer.
6803                let challenge = relay.auth_challenge().ok_or_else(|| {
6804                    SarahConversationError::Relay(
6805                        "relay refused the publish without exposing a challenge".into(),
6806                    )
6807                })?;
6808                let auth_event = EventBuilder::new(Kind::Custom(22242), "")
6809                    .tag(Tag::parse(["relay", auth_url]).expect("relay tag"))
6810                    .tag(
6811                        Tag::parse(["challenge", challenge.challenge.as_str()])
6812                            .expect("challenge tag"),
6813                    )
6814                    .sign_with_keys(keys)
6815                    .expect("signed auth event");
6816                relay.authenticate(&auth_event)?;
6817                relay.publish(record)
6818            }
6819            other => other,
6820        }
6821    }
6822
6823    /// The real production issue #31 host pump, run headless against a live relay.
6824    ///
6825    /// This is the host half of the omega#49 device proof. Everything downstream
6826    /// of the signer is the production path: `sync_issue31_host` is the same
6827    /// entry point `bootstrap` calls, so discovery v2, the pairing state machine,
6828    /// command execution, source projection and the withheld-source statement are
6829    /// all the shipped code. The only substitution is identity custody — the
6830    /// owner key is supplied as a keypair rather than drawn from
6831    /// `omega_identity::IdentityService`, because custody needs the GPUI app and
6832    /// this harness must run without a window. That substitution is recorded on
6833    /// the issue rather than hidden here: a run of this harness proves the host
6834    /// protocol, not owner key custody.
6835    ///
6836    /// Sarah's own turns are authored by a second keypair the harness holds. The
6837    /// admitted OpenAgents turn service is out of scope for a device proof and is
6838    /// deliberately not stood up; a reply published this way is a real signed
6839    /// Sarah record on a real relay, and is not evidence that the turn service
6840    /// produced it.
6841    ///
6842    /// ```sh
6843    /// OMEGA_DEVICE_PROOF_RELAY=wss://relay.openagents.com \
6844    /// OMEGA_DEVICE_PROOF_HOST_SECRET=<64 hex> \
6845    /// OMEGA_DEVICE_PROOF_SARAH_SECRET=<64 hex> \
6846    /// OMEGA_DEVICE_PROOF_DEVICE_PUBKEY=<64 hex> \
6847    /// OMEGA_DEVICE_PROOF_STATE=/path/to/state.json \
6848    ///   cargo test -p omega_effectd --lib live_device_proof_host -- --ignored --nocapture
6849    /// ```
6850    /// omega#49 / omega#46 exit 1: a device sends to Sarah and the host must
6851    /// publish the owner record the device is waiting on.
6852    ///
6853    /// The phone showed the correct pending state and no reply ever arrived.
6854    /// The command was admitted and never quarantined, so the failure was
6855    /// downstream of admission, inside `execute_issue31_action_v2`:
6856    /// `send_message` publishes the owner rumor, `enqueue_issue31_source_projection`
6857    /// then has to read it back off the relay to confirm it, and the read-back
6858    /// refused the host's own message — see `require_conversation_recipients`.
6859    /// The result degraded to `reason.omega.projection_failed` and no owner
6860    /// projection was ever enqueued for the device.
6861    ///
6862    /// This drives the real action path against a real relay, so the assertion
6863    /// is on the same bytes a phone waits for rather than on a substitute.
6864    #[test]
6865    #[ignore = "requires a live relay; set OMEGA_LIVE_RELAY_URL"]
6866    fn owner_send_confirms_and_projects_to_the_device_on_a_live_relay() {
6867        let Ok(relay_url) = std::env::var("OMEGA_LIVE_RELAY_URL") else {
6868            eprintln!("OMEGA_LIVE_RELAY_URL unset; skipping");
6869            return;
6870        };
6871        let temporary = tempfile::tempdir().expect("tempdir");
6872        let host_keys = Keys::generate();
6873        let sarah_keys = Keys::generate();
6874        let device_public_key_hex = Keys::generate().public_key().to_hex();
6875        let signer = SigningIdentity::from_keys(host_keys.clone());
6876        let owner_public_key_hex = signer.public_key_hex.clone();
6877        let mut config = SarahConversationConfig::mock_fixture();
6878        config.identity.owner_public_key_hex = owner_public_key_hex.clone();
6879        config.identity.sarah_public_key_hex = sarah_keys.public_key().to_hex();
6880        config.conversation_digest = owner_public_key_hex[..24].to_string();
6881        config.relay_url = Some(relay_url.clone());
6882        let conversation_ref = config.conversation_ref();
6883
6884        let relay = crate::nostr_websocket_relay::WebSocketRelayAdapter::new_for_keys_with_policy(
6885            vec![relay_url.clone()],
6886            host_keys,
6887            sarah_keys.public_key().to_hex(),
6888            Vec::new(),
6889            Vec::new(),
6890        )
6891        .expect("host adapter");
6892        let controller = Issue31HostController::new(Issue31HostConfiguration {
6893            host_ref: "omega.host.local".into(),
6894            host_public_key_hex: owner_public_key_hex.clone(),
6895            sarah_public_key_hex: sarah_keys.public_key().to_hex(),
6896            conversation: conversation_ref.clone(),
6897            display_name: "Local Omega".into(),
6898            relay_urls: vec![relay_url],
6899            generation: ISSUE31_NOSTR_HOST_GENERATION,
6900        })
6901        .expect("host controller");
6902        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
6903        client.issue31_state_path = Some(temporary.path().join("issue31-state.json"));
6904        // The production caller binds the controller around the executor; the
6905        // durable commit inside `send_message` requires it.
6906        client.issue31_host = Some(controller);
6907
6908        let execution = client.execute_issue31_action_v2(
6909            &Issue31CommandArguments::SendMessage {
6910                action_ref: crate::ISSUE31_ACTION_SEND_MESSAGE.into(),
6911                conversation: conversation_ref,
6912                text: "Does a send from a paired device confirm?".into(),
6913            },
6914            "idempotency.issue31.send_message:liveconfirm",
6915            "grant.omega.liveconfirm",
6916            &device_public_key_hex,
6917            1,
6918        );
6919        assert_eq!(
6920            execution.status,
6921            Issue31CommandHandlingStatus::Accepted,
6922            "the host must accept its own send; reason {:?}",
6923            execution.reason_ref
6924        );
6925        let source_event_id = execution
6926            .source_event_id
6927            .expect("an accepted send must name the owner record it produced");
6928
6929        // The owner record is not merely published — it is readable back off
6930        // the relay, which is the step that was failing.
6931        let source = client
6932            .load_issue31_source_event(&source_event_id)
6933            .expect("the owner's own message must be readable back off the relay");
6934        assert_eq!(source.pubkey, owner_public_key_hex);
6935        assert_eq!(source.record_kind, "message");
6936        assert_eq!(source.kind, crate::ISSUE31_PRIVATE_RUMOR_KIND);
6937
6938        // And the device has an owner projection waiting for it, addressed to
6939        // the device key rather than to either conversation participant.
6940        assert!(
6941            !client.issue31_private_outbox.is_empty(),
6942            "an accepted send must enqueue the owner projection the device renders"
6943        );
6944    }
6945
6946    #[test]
6947    #[ignore = "device proof host; set OMEGA_DEVICE_PROOF_RELAY"]
6948    fn live_device_proof_host() {
6949        let Ok(relay_url) = std::env::var("OMEGA_DEVICE_PROOF_RELAY") else {
6950            eprintln!("OMEGA_DEVICE_PROOF_RELAY unset; skipping");
6951            return;
6952        };
6953        let auth_url =
6954            std::env::var("OMEGA_DEVICE_PROOF_AUTH_URL").unwrap_or_else(|_| relay_url.clone());
6955        let host_keys = device_proof_keys("OMEGA_DEVICE_PROOF_HOST_SECRET");
6956        let sarah_keys = device_proof_keys("OMEGA_DEVICE_PROOF_SARAH_SECRET");
6957        // Comma-separated, so one host can admit an iOS surface and an Android
6958        // surface at the same time. omega#49 asks for a result on both, and a
6959        // host that can only ever hold one grant proves the fan-out by
6960        // assertion rather than by running it.
6961        let device_public_key_hexes: Vec<String> = std::env::var("OMEGA_DEVICE_PROOF_DEVICE_PUBKEY")
6962            .expect("OMEGA_DEVICE_PROOF_DEVICE_PUBKEY must be the surfaces' 64-hex device keys")
6963            .split(',')
6964            .map(|value| value.trim().to_string())
6965            .filter(|value| !value.is_empty())
6966            .collect();
6967        assert!(
6968            !device_public_key_hexes.is_empty(),
6969            "OMEGA_DEVICE_PROOF_DEVICE_PUBKEY must name at least one device key"
6970        );
6971        let state_path = std::path::PathBuf::from(
6972            std::env::var("OMEGA_DEVICE_PROOF_STATE")
6973                .expect("OMEGA_DEVICE_PROOF_STATE must be a durable state file path"),
6974        );
6975        let seconds: u64 = std::env::var("OMEGA_DEVICE_PROOF_SECONDS")
6976            .ok()
6977            .and_then(|value| value.parse().ok())
6978            .unwrap_or(180);
6979        let revoke_after: Option<u64> = std::env::var("OMEGA_DEVICE_PROOF_REVOKE_AFTER")
6980            .ok()
6981            .and_then(|value| value.parse().ok());
6982        let seed = std::env::var("OMEGA_DEVICE_PROOF_SEED").is_ok();
6983        let quarantine = std::env::var("OMEGA_DEVICE_PROOF_QUARANTINE").is_ok();
6984
6985        let signer = SigningIdentity::from_keys(host_keys.clone());
6986        let owner_public_key_hex = signer.public_key_hex.clone();
6987        let owner_public_key = PublicKey::from_hex(&owner_public_key_hex).expect("owner key");
6988        let sarah_public_key_hex = sarah_keys.public_key().to_hex();
6989        let mut config = SarahConversationConfig::mock_fixture();
6990        config.identity.owner_public_key_hex = owner_public_key_hex.clone();
6991        config.identity.sarah_public_key_hex = sarah_public_key_hex.clone();
6992        config.conversation_digest = owner_public_key_hex[..24].to_string();
6993        config.relay_url = Some(relay_url.clone());
6994        config.admitted_device_public_key_hexes = device_public_key_hexes.clone();
6995        config.approved_device_scopes = vec![
6996            crate::Issue31PairingScope::ObserveIssue31,
6997            crate::Issue31PairingScope::SendMessage,
6998            crate::Issue31PairingScope::InterruptTurn,
6999        ];
7000        let conversation_ref = config.conversation_ref();
7001
7002        // Seed the owner-private sources before the host starts, so the very
7003        // first projection pass has something to fan out.
7004        // Sarah publishes for the whole run, not only during the seed, so a
7005        // send from the device can be answered while the harness is live.
7006        let mut sarah_publisher = {
7007            let mut sarah_relay = crate::nostr_websocket_relay::WebSocketRelayAdapter::new_for_keys(
7008                vec![relay_url.clone()],
7009                sarah_keys.clone(),
7010            )
7011            .expect("sarah adapter");
7012            sarah_relay.connect().expect("sarah connect");
7013            Some(sarah_relay)
7014        };
7015        // The exact sources this run seeded, so the loop can say whether each
7016        // one reached each device rather than leaving "the host admits it and
7017        // never projects it" to be inferred from a device screenshot.
7018        let mut seeded_sources: Vec<(&'static str, String)> = Vec::new();
7019
7020        if seed {
7021            let sarah_relay = sarah_publisher.as_mut().expect("sarah adapter");
7022            let now = unix_now();
7023            let (greeting, greeting_rumor_id) = sarah_conversation_wrap(
7024                &sarah_keys,
7025                &owner_public_key,
7026                &owner_public_key_hex,
7027                &conversation_ref,
7028                "Sarah here. This reply crossed a real relay from a real Omega host.",
7029            );
7030            device_proof_publish(sarah_relay, &auth_url, &sarah_keys, &greeting)
7031                .expect("publish the Sarah greeting");
7032            eprintln!("device-proof: seeded Sarah message {greeting_rumor_id}");
7033            seeded_sources.push(("greeting", greeting_rumor_id));
7034
7035            // One encrypted engram, so "inspect memory" has a real source.
7036            // An engram body is a JSON object with a `mem/…` slug and a value,
7037            // not a sentence. Seeded as prose, the host received it, refused
7038            // the body, and quarantined it — so "inspect memory" had no real
7039            // source and the quarantine count was measuring this bug.
7040            let engram_plaintext = serde_json::json!({
7041                "slug": "mem/owner_reporting_preference",
7042                "value": "Chris prefers evidence-bound reports over confident summaries.",
7043            })
7044            .to_string();
7045            let engram_ciphertext = nip44::encrypt(
7046                sarah_keys.secret_key(),
7047                &owner_public_key,
7048                engram_plaintext.as_str(),
7049                nip44::Version::default(),
7050            )
7051            .expect("nip44 encrypt the engram");
7052            let engram = EventBuilder::new(
7053                Kind::Custom(crate::SARAH_ENGRAM_KIND),
7054                engram_ciphertext,
7055            )
7056            .tag(Tag::parse(["d", &"1".repeat(64)]).expect("d tag"))
7057            .tag(Tag::parse(["p", owner_public_key_hex.as_str()]).expect("p tag"))
7058            .tag(Tag::parse(["alt", "encrypted agent memory record"]).expect("alt tag"))
7059            .tag(Tag::parse(["conversation", conversation_ref.as_str()]).expect("conversation tag"))
7060            .custom_created_at(nostr::Timestamp::from(now))
7061            .sign_with_keys(&sarah_keys)
7062            .expect("signed engram");
7063            device_proof_publish(sarah_relay, &auth_url, &sarah_keys, &engram)
7064                .expect("publish the engram");
7065            eprintln!("device-proof: seeded engram {}", engram.id.to_hex());
7066            seeded_sources.push(("engram", engram.id.to_hex()));
7067
7068            if quarantine {
7069                // Inside every record-level bound and outside the projection body
7070                // contract, so the host quarantines it and must say so.
7071                // Also 44300: a source the host never receives cannot be
7072                // quarantined, so on kind 14 this proved nothing either.
7073                let unreadable = EventBuilder::new(
7074                    Kind::Custom(SARAH_TURN_RECORD_KIND),
7075                    "",
7076                )
7077                .tag(
7078                    Tag::parse(["conversation", conversation_ref.as_str()])
7079                        .expect("conversation tag"),
7080                )
7081                .custom_created_at(nostr::Timestamp::from(now + 1))
7082                .sign_with_keys(&sarah_keys)
7083                .expect("signed unreadable source");
7084                device_proof_publish(sarah_relay, &auth_url, &sarah_keys, &unreadable)
7085                    .expect("publish the unreadable source");
7086                eprintln!(
7087                    "device-proof: seeded quarantine source {}",
7088                    unreadable.id.to_hex()
7089                );
7090            }
7091        }
7092
7093        // The host reads with the owner key in custody and Sarah as the *other*
7094        // participant. `new_for_keys` collapses both roles onto one key, which
7095        // silently narrows the read: the `authors` filter becomes
7096        // `[host, host]`, so nothing Sarah signs is ever requested, and the
7097        // conversation participant set stops matching the records on the wire.
7098        let relay = crate::nostr_websocket_relay::WebSocketRelayAdapter::new_for_keys_with_policy(
7099            vec![relay_url.clone()],
7100            host_keys,
7101            sarah_public_key_hex.clone(),
7102            Vec::new(),
7103            Vec::new(),
7104        )
7105        .expect("host adapter");
7106        let host_configuration = Issue31HostConfiguration {
7107            host_ref: "omega.host.local".into(),
7108            host_public_key_hex: owner_public_key_hex.clone(),
7109            sarah_public_key_hex: sarah_public_key_hex.clone(),
7110            conversation: conversation_ref.clone(),
7111            display_name: "Local Omega".into(),
7112            relay_urls: vec![relay_url.clone()],
7113            generation: ISSUE31_NOSTR_HOST_GENERATION,
7114        };
7115        let persisted =
7116            load_issue31_host_state(&state_path, &host_configuration).expect("load durable state");
7117        let mut controller = match persisted {
7118            Some(persisted) => {
7119                eprintln!("device-proof: resumed durable host state at {state_path:?}");
7120                persisted.controller
7121            }
7122            None => Issue31HostController::new(host_configuration).expect("host controller"),
7123        };
7124        controller
7125            .set_admitted_device_policy(
7126                config.admitted_device_public_key_hexes.clone(),
7127                config.approved_device_scopes.clone(),
7128            )
7129            .expect("admit the device");
7130
7131        let mut client = SarahConversationClient::with_relay(config, Box::new(relay), signer);
7132        client.issue31_host = Some(controller);
7133        client.issue31_state_path = Some(state_path);
7134
7135        eprintln!("device-proof: host  npub/hex {owner_public_key_hex}");
7136        eprintln!("device-proof: sarah hex      {sarah_public_key_hex}");
7137        for device_public_key_hex in &device_public_key_hexes {
7138            eprintln!("device-proof: device hex     {device_public_key_hex}");
7139        }
7140        eprintln!("device-proof: conversation   {conversation_ref}");
7141        eprintln!("device-proof: relay          {relay_url}");
7142
7143        let started = std::time::Instant::now();
7144        let mut revoked = false;
7145        let mut announced_grants: BTreeMap<String, String> = BTreeMap::new();
7146        // The previous run of this harness could see that a command had been
7147        // admitted and not quarantined, and could not see what the host then
7148        // did with it. That is the whole distance between "the send failed" and
7149        // "the send failed inside `send_message` with `projection_failed`", so
7150        // the accepted-send counter and Sarah's answer are both surfaced here.
7151        let mut announced_message_seq = client.message_seq;
7152        while started.elapsed().as_secs() < seconds {
7153            match client.sync_issue31_host() {
7154                Ok(()) => {}
7155                Err(error) => eprintln!("device-proof: sync error {error}"),
7156            }
7157            if client.message_seq != announced_message_seq {
7158                announced_message_seq = client.message_seq;
7159                let turn_ref = client
7160                    .active_turn_ref
7161                    .clone()
7162                    .unwrap_or_else(|| format!("turn.{announced_message_seq}"));
7163                eprintln!(
7164                    "device-proof: OWNER SEND ACCEPTED · message_seq {announced_message_seq} · {turn_ref} · run_state {}",
7165                    client.run_state
7166                );
7167                if let Some(sarah_relay) = sarah_publisher.as_mut() {
7168                    // Sarah's half. This is the harness's own keypair, not the
7169                    // admitted OpenAgents turn service: it produces a real
7170                    // signed Sarah record on a real relay, and is not evidence
7171                    // that the turn service produced it.
7172                    // A conversation message reaches the host the same way the
7173                    // owner's own does: a NIP-59 gift wrap addressed to the
7174                    // host's custody key, carrying a kind-14 rumor. This
7175                    // harness published a bare kind 14 straight to the relay,
7176                    // which the host never subscribes to, so every reply it
7177                    // reported sending was discarded in transit and the reply
7178                    // arm of this proof had never once run.
7179                    let (reply, _reply_rumor_id) = sarah_conversation_wrap(
7180                        &sarah_keys,
7181                        &owner_public_key,
7182                        &owner_public_key_hex,
7183                        &conversation_ref,
7184                        &format!(
7185                            "Received. Answering {turn_ref} from a real Omega host over a real relay."
7186                        ),
7187                    );
7188                    match device_proof_publish(sarah_relay, &auth_url, &sarah_keys, &reply) {
7189                        Ok(()) => eprintln!(
7190                            "device-proof: SARAH REPLIED {} · {turn_ref}",
7191                            &reply.id.to_hex()[..16]
7192                        ),
7193                        Err(error) => eprintln!("device-proof: Sarah reply failed {error}"),
7194                    }
7195                }
7196            }
7197            let now = unix_now();
7198            for (event_id, reason) in &client.issue31_quarantined_events {
7199                if announced_grants
7200                    .insert(format!("quarantine:{event_id}"), reason.clone())
7201                    .as_ref()
7202                    != Some(reason)
7203                {
7204                    eprintln!("device-proof: QUARANTINED {} · {reason}", &event_id[..16]);
7205                }
7206            }
7207            for (key, substance) in &client.issue31_withheld_emissions {
7208                let line = format!("{substance:?}");
7209                if announced_grants.insert(format!("withheld:{key}"), line.clone()).as_ref()
7210                    != Some(&line)
7211                {
7212                    eprintln!("device-proof: withheld {key} · {line}");
7213                }
7214            }
7215            if let Some(host) = client.issue31_host.as_ref() {
7216                for projection in host.grant_projections(now).unwrap_or_default() {
7217                    let line = format!(
7218                        "{} · {} · generation {} · scopes {:?}",
7219                        projection.device_fingerprint,
7220                        projection.status,
7221                        projection.generation,
7222                        projection.scopes
7223                    );
7224                    if announced_grants.get(&projection.grant_ref) != Some(&line) {
7225                        eprintln!("device-proof: grant {} · {line}", projection.grant_ref);
7226                        announced_grants.insert(projection.grant_ref.clone(), line);
7227                    }
7228                    // Whether each seeded source actually reached this grant.
7229                    // "The host admits the engram and never projects it" was
7230                    // read off a device surface; the host can state it directly.
7231                    for (name, source_event_id) in &seeded_sources {
7232                        let projected = host.source_was_projected(
7233                            &projection.grant_ref,
7234                            projection.generation,
7235                            source_event_id,
7236                        );
7237                        if !projected {
7238                            continue;
7239                        }
7240                        let key = format!("projected:{}:{name}", projection.grant_ref);
7241                        if announced_grants
7242                            .insert(key, source_event_id.clone())
7243                            .as_ref()
7244                            != Some(source_event_id)
7245                        {
7246                            eprintln!(
7247                                "device-proof: PROJECTED {name} {} · {}",
7248                                &source_event_id[..16],
7249                                projection.grant_ref
7250                            );
7251                        }
7252                    }
7253                }
7254            }
7255            if let Some(revoke_after) = revoke_after {
7256                if !revoked && started.elapsed().as_secs() >= revoke_after {
7257                    let grant_refs: Vec<String> = client
7258                        .issue31_host
7259                        .as_ref()
7260                        .map(|host| {
7261                            host.grant_projections(now)
7262                                .unwrap_or_default()
7263                                .into_iter()
7264                                .filter(|projection| projection.status == "active")
7265                                .map(|projection| projection.grant_ref)
7266                                .collect()
7267                        })
7268                        .unwrap_or_default();
7269                    for grant_ref in grant_refs {
7270                        match client.revoke_issue31_grant(
7271                            &grant_ref,
7272                            Some("reason.omega.owner_revoked".to_string()),
7273                            format!("idempotency.device_proof.revoke.{grant_ref}"),
7274                            grant_ref.clone(),
7275                        ) {
7276                            Ok(_) => eprintln!("device-proof: REVOKED {grant_ref}"),
7277                            Err(error) => eprintln!("device-proof: revoke failed {error}"),
7278                        }
7279                        revoked = true;
7280                    }
7281                }
7282            }
7283            std::thread::sleep(std::time::Duration::from_secs(3));
7284        }
7285        eprintln!("device-proof: host stopped after {seconds}s");
7286    }
7287
7288}
7289
Served at tenant.openagents/omega Member data and write actions are omitted.