Skip to main content

car_messaging/
messaging_orchestrator.rs

1//! In-process iMessage approval-transport orchestrator (Units 2 & 4).
2//!
3//! The ENTIRE transport loop is daemon-side Rust, owned by `car-server-core`,
4//! running Rust-to-Rust with **no new FFI/WS surface on the approval seam and
5//! no Swift in the transport path** (the only new WS surface in this feature is
6//! the host-gated `messaging.config.*` channel in Unit 3). This module is the
7//! two halves of that loop:
8//!
9//! - **Outbound (Unit 4).** [`MessagingOrchestrator::observe_and_notify`]
10//!   polls [`HostState::approvals`] for new *fire-and-return* approvals
11//!   (`action` NOT prefixed `ws.method:` — the only discriminator the
12//!   persisted row carries, the blocking-gate convention from
13//!   `handler.rs:2183-2184`). For each new eligible approval, when the
14//!   feature is enabled and a paired handle exists, it sends ONE iMessage to
15//!   the paired handle (action summary + a short per-approval code) **in
16//!   process via the un-gated [`car_ffi_common::integrations::messages_send`]**
17//!   so the send does not itself raise a `messages.send` approval (gate loop).
18//!   An in-memory code↔approval_id map correlates the later reply.
19//!
20//! - **Inbound (Unit 4).** [`MessagingOrchestrator::handle_inbound`] takes one
21//!   [`InboundMessage`] and FIRST drops it if the sender handle is not
22//!   allowlisted (SC-7 — before ANY parse). It then parses the body to exactly
23//!   one [`InboundIntent`] — `{Approve, Deny, PairingCode, Ignore}` — and maps
24//!   it: a leading code resolves THAT approval; a bare `approve`/`deny`
25//!   resolves the sole pending eligible approval; 2+ pending and
26//!   bare/ambiguous resolves NOTHING and sends one disambiguation reply listing
27//!   the pending codes; an unknown/already-resolved code resolves nothing; a
28//!   pairing code routes to [`MessagingConfigStore::validate_and_consume_pairing_code`].
29//!   **v1 single-handle invariant:** the config store rejects a second
30//!   allowlisted handle (`MessagingConfigStore::add_handle`/`set_allowlist`),
31//!   so there is exactly one paired user. "The sole pending approval" is
32//!   therefore correct-by-invariant — all eligible pending approvals belong to
33//!   the one paired handle; there is no per-sender scoping to do in v1.
34//!   Resolution happens **in-process via [`HostState::resolve_approval`]**
35//!   (system-level rows resolve directly, no WS session, no per-session ACL).
36//!
37//! - **Poller (Unit 2).** [`MessagingOrchestrator::poll_once`] reads new
38//!   chat.db rows past the persisted watermark (via the Unit 1 reader),
39//!   advances the watermark, and forwards each new row to `handle_inbound`.
40//!   `MessagingOrchestrator::run_inbound_loop` drives `poll_once` on a bounded
41//!   interval (`max_iterations` — the no-runaway-loop primitive, mirroring the
42//!   `car-scheduler` `dream_loop` precedent), with a cancel token.
43//!
44//! ## The anti-injection wall (SC-6, structural half)
45//!
46//! The inbound path NEVER calls any config/allowlist mutator. Its only effects
47//! are: (1) resolve a known pending fire-and-return approval id, (2) send a
48//! disambiguation reply, or (3) validate-and-consume a pairing code. The
49//! `MessagingConfigStore` privileged mutators (the enabled-flag and allowlist
50//! setters) are reachable ONLY from the host-gated `messaging.config.*` WS
51//! surface — an inbound iMessage carries no such credential. A grep over THIS
52//! file for any config-mutator call name finds ZERO matches: the inbound path
53//! touches only `resolve_approval` and `validate_and_consume_pairing_code`.
54
55use crate::channel_supervisor::{
56    liveness_record_failure, liveness_record_success, ChannelSupervisor, SharedLiveness,
57};
58use crate::messaging_config::MessagingConfigStore;
59use car_ffi_common::integrations::InboundMessage;
60use car_proto::approval_summary::{approval_summary, sanitize_line};
61use car_proto::HostApprovalRequest;
62#[cfg(test)]
63use car_proto::HostApprovalStatus;
64use car_server_types::approval_core::{ApprovalCore, ResolveOutcome};
65use car_server_types::channel::{ChannelId, InboundChannel, InboundSink, SharedHost};
66// `CancelSignal` is only referenced by the macOS iMessage adapter below.
67#[cfg(target_os = "macos")]
68use car_server_types::channel::CancelSignal;
69use car_server_types::host::HostState;
70use std::collections::HashMap;
71use std::sync::Arc;
72use tokio::sync::Mutex;
73
74/// The system-raised principal the iMessage adapter supplies to
75/// [`ApprovalCore::resolve`]. The literal lives HERE (in the iMessage adapter),
76/// passed INTO the channel-agnostic core — it must NOT appear in
77/// `approval_core.rs` (MC-3 edge).
78const IMESSAGE_PRINCIPAL: &str = "imessage-transport";
79
80/// The resolution string the transport supplies on an approve.
81/// Matches the gate's `approve_label` (`handler.rs:2212` → `"approve"`), so
82/// `classify_resolution` reads an inbound approve exactly as a CarHost click.
83const APPROVE: &str = "approve";
84/// The resolution string on a deny. Any non-`approve` string classifies as
85/// denied; `"deny"` is the explicit, human-readable choice.
86const DENY: &str = "deny";
87
88/// The fixed, unmistakable body the `messaging.test_send` self-test sends (U4).
89/// It is plainly NOT an approval prompt (no code, no "reply approve/deny"), so
90/// it can never be mistaken for one — and on a solo Apple ID its echo back
91/// parses to `Ignore` (the closed-grammar parser drops any non-command body).
92pub const TEST_SEND_BODY: &str =
93    "CAR test: your iMessage approvals are connected. No action needed.";
94
95/// The structured result of one send attempt (U3). Distinguishes the THREE
96/// outcomes the orchestrator must record, instead of collapsing two of them to
97/// "success" as the pre-U3 `.map(|_| ())` did:
98///
99/// - `Err(reason)` from [`MessageSender::send`] = a HARD failure (osascript
100///   killed by a TCC/Automation denial, or a non-macOS no-op). The caller rolls
101///   back the code mapping + retries (existing reliability contract).
102/// - `Ok(SendOutcome { sent: false, reason })` = a SOFT failure — the JXA send
103///   returned `sent:false` (recipient-not-found / no Messages service). Pre-U3
104///   this was silently treated as success (`.map(|_| ())` discarded the bool);
105///   it is now recorded as a FAILURE with the reason (U3 regression guard).
106/// - `Ok(SendOutcome { sent: true, .. })` = a genuine success.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SendOutcome {
109    /// Whether the message was actually delivered (`sent` from the JXA send).
110    pub sent: bool,
111    /// Failure reason when `sent == false`; `None` on success.
112    pub reason: Option<String>,
113}
114
115impl SendOutcome {
116    /// A delivered send.
117    pub fn ok() -> Self {
118        Self {
119            sent: true,
120            reason: None,
121        }
122    }
123
124    /// A soft failure (the send returned `sent:false`) with a reason.
125    pub fn soft_fail(reason: impl Into<String>) -> Self {
126        Self {
127            sent: false,
128            reason: Some(reason.into()),
129        }
130    }
131}
132
133/// Synchronous, injectable outbound-send seam. The production impl
134/// ([`RealMessageSender`]) calls the un-gated
135/// [`car_ffi_common::integrations::messages_send`]; tests substitute a
136/// capturing spy recording `(handle, body)` so SC-3/SC-5 assert the outbound
137/// behavior with no Messages.app.
138pub trait MessageSender: Send + Sync {
139    /// Send one iMessage `body` to `handle`. Returns:
140    /// - `Err(reason)` on a HARD failure (the orchestrator logs + rolls back +
141    ///   retries; never panics).
142    /// - `Ok(SendOutcome { sent, reason })` otherwise — `sent:true` for a
143    ///   delivered message, `sent:false` (with a reason) for a SOFT failure the
144    ///   pre-U3 code swallowed as success.
145    fn send(&self, handle: &str, body: &str) -> Result<SendOutcome, String>;
146}
147
148/// Production send: routes through the un-gated plain Rust
149/// [`car_ffi_common::integrations::messages_send`] (`integrations.rs:105`) so
150/// the transport's own send does NOT raise a `messages.send` approval and loop
151/// the gate. On non-macOS the underlying backend returns an `Err` at runtime
152/// (the symbol still links), so this builds on every platform.
153#[derive(Debug, Clone, Default)]
154pub struct RealMessageSender;
155
156impl MessageSender for RealMessageSender {
157    fn send(&self, handle: &str, body: &str) -> Result<SendOutcome, String> {
158        // Build the `SendRequest` JSON the plain-Rust `messages_send` parses.
159        let req = serde_json::json!({ "recipient": handle, "body": body });
160        let req_json = req.to_string();
161        // U3: inspect the returned `SendResult` JSON instead of discarding it
162        // with `.map(|_| ())`. The JXA backend returns
163        // `{ available, reason, sent }`: a `sent:false` (recipient-not-found, no
164        // Messages service) is a SOFT failure that the pre-U3 code counted as
165        // success. Surface it as `Ok(SendOutcome::soft_fail(reason))` so the
166        // caller records it as a failure; a hard osascript error is already an
167        // `Err` from `messages_send`.
168        let value = car_ffi_common::integrations::messages_send(&req_json)?;
169        let sent = value.get("sent").and_then(|v| v.as_bool()).unwrap_or(false);
170        if sent {
171            Ok(SendOutcome::ok())
172        } else {
173            let reason = value
174                .get("reason")
175                .and_then(|v| v.as_str())
176                .map(|s| s.to_string())
177                .unwrap_or_else(|| "Messages reported the message was not sent".to_string());
178            Ok(SendOutcome::soft_fail(reason))
179        }
180    }
181}
182
183/// The parsed meaning of an inbound iMessage body. **Closed set** — the parser
184/// yields exactly one of these and nothing else, so there is no inbound→config
185/// edge by construction (SC-6).
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum InboundIntent {
188    /// `approve`, optionally naming a specific per-approval code.
189    Approve { code: Option<String> },
190    /// `deny`, optionally naming a specific per-approval code.
191    Deny { code: Option<String> },
192    /// A standalone token that looks like a pairing code (the high-entropy
193    /// base64url-no-pad code minted by `messaging.pairing.start`).
194    PairingCode(String),
195    /// Anything else — silently ignored. NOT a config mutation, ever.
196    Ignore,
197}
198
199/// Length of a minted pairing code (`mint_pairing_code` → 32 bytes
200/// base64url-no-pad = 43 chars). Used by the parser to recognize a standalone
201/// token as a pairing-code candidate (vs. a short per-approval code).
202const PAIRING_CODE_LEN: usize = 43;
203
204/// Short per-approval code length (e.g. `A7`). Kept tiny so it is easy to type
205/// back. The leading letter avoids collision with a bare number.
206const APPROVAL_CODE_LEN: usize = 2;
207
208/// In-process iMessage adapter (re-homed from #403's orchestrator). One per
209/// daemon; held behind an `Arc` and driven by its `InboundChannel::run()` poll
210/// loop. Cheap to clone the `Arc`. The channel-agnostic approval semantics live
211/// in [`ApprovalCore`]; this struct owns the iMessage-specific transport
212/// (chat.db poll, CodeMap text-code correlation, parse grammar).
213pub struct MessagingOrchestrator {
214    /// Channel-agnostic approval semantics (eligibility + resolve). The
215    /// `"imessage-transport"` principal is supplied by this adapter at each
216    /// `core.resolve(...)` call — it is not baked into the core.
217    core: ApprovalCore,
218    config: MessagingConfigStore,
219    sender: Arc<dyn MessageSender>,
220    /// Base dir for the inbound-read watermark (the `~/.car/` equivalent).
221    /// Injectable so tests never touch the developer's real `~/.car/`.
222    base_dir: std::path::PathBuf,
223    /// Bidirectional correlation between the short per-approval code we put in
224    /// the outbound prompt and the approval id. `code → approval_id` resolves
225    /// an inbound `<code> approve`; `approval_id → code` lets us (a) skip
226    /// re-sending a prompt for an approval we already notified, and (b) list
227    /// the pending codes in a disambiguation reply.
228    codes: Mutex<CodeMap>,
229    /// SC-7 instrumentation: count of inbound bodies that reached the parser.
230    /// A non-allowlisted sender's row must NOT increment this (it is dropped
231    /// before parse). Tests assert this stays 0 for a dropped row.
232    parse_calls: std::sync::atomic::AtomicU64,
233    /// Optional shared per-channel liveness sink (U2/U3). When `Some`, every
234    /// outbound send records its structured outcome (success / soft / hard
235    /// failure) here so `messaging.status` can report "last delivered" + the
236    /// last error. `None` for adapters built without a liveness sink (the #403
237    /// direct tests that don't assert liveness). The channel this adapter
238    /// serves is fixed at [`ChannelId::IMessage`] for outcome keying.
239    liveness: Option<SharedLiveness>,
240}
241
242/// The code↔approval_id correlation, kept together so both directions stay
243/// consistent under one lock.
244#[derive(Default)]
245struct CodeMap {
246    code_to_id: HashMap<String, String>,
247    id_to_code: HashMap<String, String>,
248    /// Monotonic counter feeding the short code suffix, so codes don't repeat
249    /// within a daemon uptime even after the map is cleaned.
250    next: u64,
251}
252
253impl CodeMap {
254    /// Mint the next short per-approval code (e.g. `A7`, `B12`). Letter cycles
255    /// A–Z, number increments — readable and easy to type back.
256    fn mint(&mut self) -> String {
257        let n = self.next;
258        self.next += 1;
259        let letter = (b'A' + (n % 26) as u8) as char;
260        let num = n / 26;
261        format!("{letter}{num}")
262    }
263}
264
265impl MessagingOrchestrator {
266    /// Build an orchestrator over the given host, config store, send seam, and
267    /// `~/.car/`-equivalent base dir. Production passes
268    /// `RealMessageSender`/`MessagingConfigStore::from_home()`/`~/.car`; tests
269    /// pass a spy sender and a temp base dir.
270    pub fn new(
271        host: Arc<HostState>,
272        config: MessagingConfigStore,
273        sender: Arc<dyn MessageSender>,
274        base_dir: impl Into<std::path::PathBuf>,
275    ) -> Self {
276        Self {
277            core: ApprovalCore::new(host),
278            config,
279            sender,
280            base_dir: base_dir.into(),
281            codes: Mutex::new(CodeMap::default()),
282            parse_calls: std::sync::atomic::AtomicU64::new(0),
283            liveness: None,
284        }
285    }
286
287    /// Build an orchestrator that ALSO records every outbound send's outcome
288    /// into the shared per-channel liveness map (U2/U3). The boot path and the
289    /// runtime-enable spawn closure use this so `messaging.status` sees real
290    /// "last delivered" + last-error state; the `messaging.test_send` self-test
291    /// (U4) relies on the same recording so a test failure surfaces identically
292    /// to a real one.
293    pub fn with_liveness(
294        host: Arc<HostState>,
295        config: MessagingConfigStore,
296        sender: Arc<dyn MessageSender>,
297        base_dir: impl Into<std::path::PathBuf>,
298        liveness: SharedLiveness,
299    ) -> Self {
300        Self {
301            core: ApprovalCore::new(host),
302            config,
303            sender,
304            base_dir: base_dir.into(),
305            codes: Mutex::new(CodeMap::default()),
306            parse_calls: std::sync::atomic::AtomicU64::new(0),
307            liveness: Some(liveness),
308        }
309    }
310
311    /// Record a send `outcome` into the shared liveness (if a sink is wired),
312    /// keyed to this adapter's channel (iMessage). A hard `Err` is recorded by
313    /// [`Self::record_hard_failure`]; this handles the `Ok(SendOutcome)` shape:
314    /// `sent:true` → success; `sent:false` → soft failure with its reason (the
315    /// case the pre-U3 code swallowed).
316    fn record_outcome(&self, outcome: &SendOutcome) {
317        let Some(liveness) = &self.liveness else {
318            return;
319        };
320        if outcome.sent {
321            liveness_record_success(liveness, ChannelId::IMessage);
322        } else {
323            let reason = outcome
324                .reason
325                .clone()
326                .unwrap_or_else(|| "send failed".to_string());
327            liveness_record_failure(liveness, ChannelId::IMessage, reason);
328        }
329    }
330
331    /// Record a HARD send failure (an `Err` from the sender — osascript killed
332    /// by an Automation/TCC denial) into the shared liveness.
333    fn record_hard_failure(&self, reason: &str) {
334        if let Some(liveness) = &self.liveness {
335            liveness_record_failure(liveness, ChannelId::IMessage, reason);
336        }
337    }
338
339    /// The shared host this adapter resolves against (the eviction sweep and
340    /// pending-code queries borrow it). Routed through the core so there is one
341    /// `Arc<HostState>`.
342    fn host(&self) -> &Arc<HostState> {
343        self.core.host()
344    }
345
346    /// Test/diagnostic accessor: how many inbound bodies reached the parser.
347    /// SC-7 asserts this is 0 after a non-allowlisted row is fed.
348    pub fn parse_call_count(&self) -> u64 {
349        self.parse_calls.load(std::sync::atomic::Ordering::Relaxed)
350    }
351
352    // -------------------------------------------------------------------
353    // OUTBOUND (Unit 4): observe new approvals → send one prompt each
354    // -------------------------------------------------------------------
355
356    /// Observe new eligible approvals on [`HostState`] and send ONE iMessage
357    /// prompt per newly-seen one. Idempotent per approval: an approval we have
358    /// already minted a code for is skipped, so calling this every tick sends
359    /// at most one prompt per approval.
360    ///
361    /// Gating (the enabled-flag wall): if the feature is disabled OR there is
362    /// no paired handle, this is a silent no-op — zero sends. Excluded rows:
363    /// any `ws.method:*` blocking-gate row (wrong producer) and any
364    /// already-resolved row.
365    ///
366    /// The single paired recipient is the FIRST allowlisted handle (v1 is one
367    /// paired/allowlisted user — ledger item 2). A send failure is logged and
368    /// does NOT poison the loop; the code stays mapped so the inbound reply
369    /// still correlates if the message did go through.
370    pub async fn observe_and_notify(&self) {
371        // Enabled-flag gate FIRST — feature off ⇒ no reads of approvals state
372        // matter, but cheap-exit anyway so an off feature does zero work.
373        if !self.config.is_enabled().unwrap_or(false) {
374            return;
375        }
376        let Some(recipient) = self.paired_handle() else {
377            return; // No paired handle ⇒ nobody to notify.
378        };
379
380        let approvals = self.host().approvals().await;
381
382        // Eviction sweep: drop code↔id entries whose approval is no longer
383        // present-and-Pending (resolved via a CarHost click, reaped, or pruned).
384        // Without this the map grows unbounded over a long daemon uptime, since
385        // the inbound resolve path only evicts codes IT consumes. Bound it here
386        // every tick against the live approval set.
387        {
388            let live_pending: std::collections::HashSet<&str> = approvals
389                .iter()
390                .filter(|a| ApprovalCore::is_eligible_pending(a))
391                .map(|a| a.id.as_str())
392                .collect();
393            let mut codes = self.codes.lock().await;
394            let stale: Vec<String> = codes
395                .id_to_code
396                .keys()
397                .filter(|id| !live_pending.contains(id.as_str()))
398                .cloned()
399                .collect();
400            for id in stale {
401                if let Some(code) = codes.id_to_code.remove(&id) {
402                    codes.code_to_id.remove(&code);
403                }
404            }
405        }
406
407        for approval in approvals {
408            if !ApprovalCore::is_eligible_pending(&approval) {
409                continue;
410            }
411            // Skip approvals we've already prompted for (idempotency).
412            let code = {
413                let mut codes = self.codes.lock().await;
414                if codes.id_to_code.contains_key(&approval.id) {
415                    continue;
416                }
417                let code = codes.mint();
418                codes.code_to_id.insert(code.clone(), approval.id.clone());
419                codes.id_to_code.insert(approval.id.clone(), code.clone());
420                code
421            };
422            let body = outbound_body(&approval, &code);
423            match self.sender.send(&recipient, &body) {
424                Ok(outcome) => {
425                    // U3: record the structured outcome. A soft `sent:false`
426                    // (recipient-not-found / no Messages service) is recorded as
427                    // a FAILURE here — the case pre-U3 silently counted as
428                    // success. The code mapping is KEPT on a soft failure (the
429                    // approval is still pending; a later tick re-attempts via the
430                    // idempotency guard) — only a HARD error rolls back to force
431                    // a re-mint.
432                    self.record_outcome(&outcome);
433                }
434                Err(e) => {
435                    self.record_hard_failure(&e);
436                    // Roll back the just-minted mapping so this approval is NOT
437                    // permanently suppressed. The idempotency guard above skips
438                    // any approval already in `id_to_code`, and the eviction
439                    // sweep only fires once the approval leaves the pending set —
440                    // so leaving a mapped-but-unsent code here would drop the
441                    // prompt forever on a single transient Messages failure.
442                    // Removing it lets the next tick re-mint (a fresh code via
443                    // the monotonic counter — fine) and re-send.
444                    {
445                        let mut codes = self.codes.lock().await;
446                        if let Some(c) = codes.id_to_code.remove(&approval.id) {
447                            codes.code_to_id.remove(&c);
448                        }
449                    }
450                    tracing::warn!(
451                        approval_id = %approval.id,
452                        error = %e,
453                        "iMessage approval prompt send failed; rolled back code, will retry next tick"
454                    );
455                }
456            }
457        }
458    }
459
460    /// Fan-out outbound (Unit 5): send the iMessage prompt for `approval` using
461    /// a SHARED `code` minted ONCE by the fan-out coordinator (not this
462    /// adapter's `CodeMap.mint`), so iMessage and Slack carry the SAME code
463    /// (MC-8). Records `code ↔ approval_id` into this adapter's `CodeMap` so the
464    /// inbound text-resolve path still correlates a reply. Idempotent per
465    /// approval: an approval already mapped here is skipped (no duplicate send).
466    /// A send failure rolls back the mapping (same reliability contract as
467    /// `observe_and_notify`).
468    ///
469    /// The rendered body is byte-for-byte the same `outbound_body` the iMessage
470    /// poll path uses — only the code SOURCE differs (shared vs self-minted), so
471    /// the iMessage grammar/behavior is unchanged (MC-1). Gating (enabled flag +
472    /// paired handle) is applied here too.
473    pub async fn send_shared_prompt(&self, approval: &HostApprovalRequest, code: &str) {
474        if !self.config.is_enabled().unwrap_or(false) {
475            return;
476        }
477        let Some(recipient) = self.paired_handle() else {
478            return;
479        };
480        // Idempotency + record the shared code in this adapter's map so an
481        // inbound `<code> approve` still resolves. Skip if already mapped.
482        {
483            let mut codes = self.codes.lock().await;
484            if codes.id_to_code.contains_key(&approval.id) {
485                return;
486            }
487            codes
488                .code_to_id
489                .insert(code.to_string(), approval.id.clone());
490            codes
491                .id_to_code
492                .insert(approval.id.clone(), code.to_string());
493        }
494        let body = outbound_body(approval, code);
495        match self.sender.send(&recipient, &body) {
496            Ok(outcome) => {
497                // U3: record success / soft-failure into liveness.
498                self.record_outcome(&outcome);
499            }
500            Err(e) => {
501                self.record_hard_failure(&e);
502                // Roll back so a transient failure does not permanently suppress.
503                let mut codes = self.codes.lock().await;
504                if let Some(c) = codes.id_to_code.remove(&approval.id) {
505                    codes.code_to_id.remove(&c);
506                }
507                tracing::warn!(
508                    approval_id = %approval.id,
509                    error = %e,
510                    "iMessage shared-code prompt send failed; rolled back, will retry next tick"
511                );
512            }
513        }
514    }
515
516    // -------------------------------------------------------------------
517    // INBOUND (Unit 4): allowlist-drop → parse → resolve / pair / disambiguate
518    // -------------------------------------------------------------------
519
520    /// Handle one inbound row. SC-7: a non-allowlisted sender is dropped
521    /// BEFORE any parse (the parse counter does not move). Then the body is
522    /// parsed to one [`InboundIntent`] and mapped:
523    ///
524    /// - `<code> approve|deny` → resolve THAT approval id (if it is a known,
525    ///   still-pending, eligible approval).
526    /// - bare `approve|deny` → resolve the SOLE pending eligible approval; if
527    ///   2+ are pending, resolve nothing and send one disambiguation reply.
528    ///   (v1 has one paired handle — see the module-level single-handle
529    ///   invariant — so "the sole pending approval" needs no per-sender scope.)
530    /// - unknown / already-resolved code → resolve nothing.
531    /// - pairing code → validate-and-consume (the ONLY inbound-reachable
532    ///   config mutation; binds the sender only on a constant-time code match).
533    /// - ignore → no-op.
534    ///
535    /// The enabled-flag gate applies: feature off ⇒ the row is dropped with no
536    /// parse/resolve/send.
537    pub async fn handle_inbound(&self, msg: &InboundMessage) {
538        // Enabled-flag wall: a disabled feature does zero inbound work.
539        if !self.config.is_enabled().unwrap_or(false) {
540            return;
541        }
542        // SC-7: drop non-allowlisted senders BEFORE any parse. is_allowlisted
543        // errs only on a malformed config file; treat an error as "not
544        // allowlisted" (fail closed).
545        if !self.config.is_allowlisted(&msg.handle_id).unwrap_or(false) {
546            return;
547        }
548
549        // Past the allowlist wall: now (and only now) we parse.
550        self.parse_calls
551            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
552        let intent = parse_inbound(&msg.body);
553
554        match intent {
555            InboundIntent::Approve { code } => {
556                self.resolve_intent(&msg.handle_id, code, APPROVE).await;
557            }
558            InboundIntent::Deny { code } => {
559                self.resolve_intent(&msg.handle_id, code, DENY).await;
560            }
561            InboundIntent::PairingCode(candidate) => {
562                // The ONLY inbound-reachable config mutation. Binds the sender
563                // ONLY on a constant-time match of the locally-minted code.
564                let _ = self
565                    .config
566                    .validate_and_consume_pairing_code(&msg.handle_id, &candidate);
567            }
568            InboundIntent::Ignore => {}
569        }
570    }
571
572    /// Map an approve/deny intent (with or without a code) onto a resolve.
573    /// Encapsulates the ledger rule: a code names exactly one approval; a bare
574    /// word resolves the sole pending eligible approval; 2+ pending and
575    /// bare/ambiguous resolves nothing and sends a disambiguation reply. (v1's
576    /// single-handle invariant means all eligible pending approvals belong to
577    /// the one paired user, so the bare-word "sole pending" rule is unambiguous
578    /// without per-sender scoping.)
579    async fn resolve_intent(&self, handle: &str, code: Option<String>, resolution: &str) {
580        match code {
581            Some(code) => {
582                // Named code: resolve THAT approval iff it maps to a known,
583                // still-pending, eligible approval. Unknown / already-resolved
584                // ⇒ resolve nothing (no disambiguation — the sender named a
585                // specific code, it just isn't actionable).
586                if let Some(approval_id) = self.lookup_pending_code(&code).await {
587                    self.resolve(&approval_id, resolution).await;
588                }
589            }
590            None => {
591                // Bare word: resolve the SOLE pending eligible approval. 0 ⇒
592                // nothing to do; 1 ⇒ resolve it; 2+ ⇒ disambiguate.
593                let pending = self.pending_codes().await;
594                match pending.len() {
595                    0 => {}
596                    1 => {
597                        let approval_id = pending[0].1.clone();
598                        self.resolve(&approval_id, resolution).await;
599                    }
600                    _ => {
601                        let reply = disambiguation_body(&pending);
602                        // A disambiguation reply is an internal nudge, not an
603                        // approval-delivery signal, so it is NOT recorded into
604                        // liveness (which tracks approval-prompt delivery). A
605                        // hard error is logged; a soft `sent:false` is ignored
606                        // (the user will simply re-send their reply).
607                        if let Err(e) = self.sender.send(handle, &reply) {
608                            tracing::warn!(error = %e, "disambiguation reply send failed");
609                        }
610                    }
611                }
612            }
613        }
614    }
615
616    /// Resolve `approval_id` in-process via the channel-agnostic
617    /// [`ApprovalCore::resolve`] (which calls the untouched
618    /// `HostState::resolve_approval`). The iMessage adapter supplies its own
619    /// system-raised principal ([`IMESSAGE_PRINCIPAL`] = `"imessage-transport"`)
620    /// — the literal lives in this adapter, not in the core (MC-3 edge).
621    ///
622    /// On a real resolve ([`ResolveOutcome::Resolved`]) the consumed code is
623    /// dropped so a re-send of the same word doesn't re-resolve and it stops
624    /// showing in disambiguation lists. On a fan-out non-resolve
625    /// ([`ResolveOutcome::StillPending`]) or an error the code is NOT evicted —
626    /// the approval did not move, so the user's reply should be able to retry.
627    async fn resolve(&self, approval_id: &str, resolution: &str) {
628        if self
629            .core
630            .resolve(IMESSAGE_PRINCIPAL, approval_id, resolution)
631            .await
632            == ResolveOutcome::Resolved
633        {
634            let mut codes = self.codes.lock().await;
635            if let Some(code) = codes.id_to_code.remove(approval_id) {
636                codes.code_to_id.remove(&code);
637            }
638        }
639    }
640
641    /// Resolve a named code to its approval id IFF that approval is still
642    /// pending and eligible. Unknown code, or a code whose approval has been
643    /// resolved/excluded, yields `None` (resolve nothing).
644    async fn lookup_pending_code(&self, code: &str) -> Option<String> {
645        let approval_id = {
646            let codes = self.codes.lock().await;
647            codes.code_to_id.get(code).cloned()?
648        };
649        // Confirm the approval is still a pending, eligible row.
650        self.core
651            .is_id_eligible_pending(&approval_id)
652            .await
653            .then_some(approval_id)
654    }
655
656    /// The `(code, approval_id, action)` triples that are still pending +
657    /// eligible, sorted by code for a stable disambiguation listing. The action
658    /// rides along so the listing can name what each code refers to.
659    async fn pending_codes(&self) -> Vec<(String, String, String)> {
660        let pending_actions: std::collections::HashMap<String, String> = self
661            .core
662            .eligible_pending()
663            .await
664            .into_iter()
665            .map(|a| (a.id, a.action))
666            .collect();
667        let codes = self.codes.lock().await;
668        let mut out: Vec<(String, String, String)> = codes
669            .code_to_id
670            .iter()
671            .filter_map(|(c, id)| {
672                pending_actions
673                    .get(id)
674                    .map(|action| (c.clone(), id.clone(), action.clone()))
675            })
676            .collect();
677        out.sort_by(|a, b| a.0.cmp(&b.0));
678        out
679    }
680
681    /// The single paired recipient: the first allowlisted handle (v1 is one
682    /// paired user). `None` when the allowlist is empty.
683    fn paired_handle(&self) -> Option<String> {
684        self.config.allowlist().ok()?.into_iter().next()
685    }
686
687    // -------------------------------------------------------------------
688    // SELF-TEST (U4): on-demand "does my Mac actually text my phone" probe
689    // -------------------------------------------------------------------
690
691    /// Send the fixed, clearly-labeled self-test message to the paired handle
692    /// and return the outcome (U4). This is a PURE send probe: it composes
693    /// [`TEST_SEND_BODY`], sends through the SAME [`MessageSender`] the real
694    /// approval path uses, and records the outcome into liveness (so a passing
695    /// test genuinely proves Automation works and a failing one surfaces the
696    /// same way a real send failure does). It mints **no** per-approval/pairing
697    /// code mapping and resolves nothing — the `CodeMap` is untouched.
698    ///
699    /// Returns `Ok(())` when the message was delivered (`sent:true`), or
700    /// `Err(reason)` for: feature disabled, no paired handle, a hard send error
701    /// (Automation denied), or a soft `sent:false` (recipient-not-found). The
702    /// error strings are user-actionable (surfaced verbatim in the pane).
703    pub async fn send_test(&self) -> Result<(), String> {
704        if !self.config.is_enabled().unwrap_or(false) {
705            return Err("iMessage is off — turn it on first.".to_string());
706        }
707        let Some(recipient) = self.paired_handle() else {
708            return Err("No paired handle — text the pairing code first.".to_string());
709        };
710        match self.sender.send(&recipient, TEST_SEND_BODY) {
711            Ok(outcome) => {
712                // Record the outcome (success or soft failure) so "last
713                // delivered" + last-error reflect the probe, identical to a
714                // real send.
715                self.record_outcome(&outcome);
716                if outcome.sent {
717                    Ok(())
718                } else {
719                    Err(outcome
720                        .reason
721                        .unwrap_or_else(|| "the message was not sent".to_string()))
722                }
723            }
724            Err(e) => {
725                self.record_hard_failure(&e);
726                Err(e)
727            }
728        }
729    }
730
731    // -------------------------------------------------------------------
732    // POLLER (Unit 2): bounded interval read past the watermark
733    // -------------------------------------------------------------------
734
735    /// One poll tick: read new chat.db rows past the persisted watermark,
736    /// advance the watermark, and forward each new row to `handle_inbound`.
737    /// Does NO parse/resolve itself — that is `handle_inbound`'s job; the
738    /// poller is purely the change-detection tick.
739    ///
740    /// Watermark semantics: on a fresh/missing watermark, seed it to the
741    /// current `MAX(ROWID)` so no pre-existing text is ever replayed as new
742    /// (and return without forwarding anything that first tick). Otherwise read
743    /// strictly past `last_rowid`, forward, then persist the new high-water =
744    /// the max rowid seen this tick. The watermark advances monotonically.
745    ///
746    /// `read_max`/`read_new` are injected so the test can drive a temp chat.db
747    /// (or a synthetic source) with no macOS dependency; production passes the
748    /// macOS-gated default-DB readers.
749    pub async fn poll_once<FMax, FNew>(
750        &self,
751        read_max: FMax,
752        read_new: FNew,
753    ) -> Result<usize, String>
754    where
755        FMax: Fn() -> Result<i64, String>,
756        FNew: Fn(i64) -> Result<Vec<InboundMessage>, String>,
757    {
758        // Enabled-flag wall: a disabled feature performs NO chat.db read.
759        if !self.config.is_enabled().unwrap_or(false) {
760            return Ok(0);
761        }
762
763        use car_ffi_common::integrations::Watermark;
764        let existing = Watermark::load(&self.base_dir).map_err(|e| e.to_string())?;
765
766        let last = match existing {
767            Some(w) => w.last_rowid,
768            None => {
769                // Fresh boot: seed to MAX(ROWID) so old texts never replay.
770                let seed = read_max()?;
771                Watermark::new(seed)
772                    .persist(&self.base_dir)
773                    .map_err(|e| e.to_string())?;
774                return Ok(0);
775            }
776        };
777
778        let rows = read_new(last)?;
779        if rows.is_empty() {
780            return Ok(0);
781        }
782
783        // Forward each new row, then advance the watermark to the max rowid
784        // seen this tick. Persist AFTER forwarding so a crash mid-tick replays
785        // (at-least-once) rather than silently dropping a reply.
786        let mut max_seen = last;
787        for row in &rows {
788            if row.rowid > max_seen {
789                max_seen = row.rowid;
790            }
791            self.handle_inbound(row).await;
792        }
793        if max_seen > last {
794            Watermark::new(max_seen)
795                .persist(&self.base_dir)
796                .map_err(|e| e.to_string())?;
797        }
798        Ok(rows.len())
799    }
800
801    /// One poll tick against the user's REAL Messages library (macOS only).
802    /// Wraps `poll_once` with the production default-DB readers
803    /// (`messages_max_rowid` / `messages_read_inbound`). Returns 0 on a fresh
804    /// seed or an empty tick.
805    #[cfg(target_os = "macos")]
806    pub async fn poll_once_default_db(&self) -> Result<usize, String> {
807        self.poll_once(
808            || car_ffi_common::integrations::messages_max_rowid().map_err(|e| e.to_string()),
809            |min| {
810                car_ffi_common::integrations::messages_read_inbound(min).map_err(|e| e.to_string())
811            },
812        )
813        .await
814    }
815
816    /// Bounded interval poll loop — the no-runaway-loop primitive, and the body
817    /// of this adapter's [`InboundChannel::run`]. Each tick runs
818    /// `observe_and_notify` (outbound) then one `poll_once` against the real
819    /// Messages library (inbound), sleeps `interval`, and stops after
820    /// `max_iterations` ticks (when `Some`) or on cancel. Mirrors the
821    /// `car-scheduler` `dream_loop` bounded-interval precedent rather than a
822    /// hand-rolled `loop {}`.
823    ///
824    /// A failing tick is logged and the loop continues (one bad read must not
825    /// kill the transport). `cancel` is a `tokio::sync::watch` the daemon
826    /// shutdown path flips to `true`.
827    #[cfg(target_os = "macos")]
828    pub async fn run_inbound_loop(
829        &self,
830        interval: std::time::Duration,
831        max_iterations: Option<u32>,
832        mut cancel: tokio::sync::watch::Receiver<bool>,
833    ) {
834        let mut iterations: u32 = 0;
835        loop {
836            if let Some(max) = max_iterations {
837                if iterations >= max {
838                    break;
839                }
840            }
841
842            // Outbound first (prompt newly-raised approvals), then inbound
843            // (drain replies). Both are best-effort; a panic in one is the
844            // caller's concern (the boot site wraps the spawn).
845            self.observe_and_notify().await;
846            if let Err(e) = self.poll_once_default_db().await {
847                tracing::debug!(error = %e, "messaging poll tick failed");
848            }
849            iterations += 1;
850
851            if let Some(max) = max_iterations {
852                if iterations >= max {
853                    break;
854                }
855            }
856
857            tokio::select! {
858                _ = tokio::time::sleep(interval) => {}
859                _ = cancel.changed() => {
860                    if *cancel.borrow() {
861                        break;
862                    }
863                }
864            }
865        }
866    }
867
868    /// Inbound-ONLY bounded poll loop (Unit 5 boot path). Each tick drains
869    /// chat.db replies via `poll_once_default_db` — but does NOT run
870    /// `observe_and_notify`, because under multi-channel fan-out the OUTBOUND
871    /// prompt is driven once by the [`crate::fanout::FanoutCoordinator`] (so
872    /// iMessage and Slack carry the SAME shared code, MC-8). Separating inbound
873    /// from outbound here is what lets the boot path notify both channels with
874    /// one shared code without double-prompting iMessage. `observe_and_notify`
875    /// and `run_inbound_loop` stay intact for the #403 direct-call tests (MC-1).
876    #[cfg(target_os = "macos")]
877    pub async fn run_inbound_only_loop(
878        &self,
879        interval: std::time::Duration,
880        mut cancel: tokio::sync::watch::Receiver<bool>,
881    ) {
882        loop {
883            if let Err(e) = self.poll_once_default_db().await {
884                tracing::debug!(error = %e, "messaging inbound poll tick failed");
885            }
886            tokio::select! {
887                _ = tokio::time::sleep(interval) => {}
888                _ = cancel.changed() => {
889                    if *cancel.borrow() {
890                        break;
891                    }
892                }
893            }
894        }
895    }
896}
897
898// ===================================================================
899// The iMessage adapter as the FIRST `InboundChannel` (Unit 3)
900// ===================================================================
901
902/// The iMessage adapter implements the channel-agnostic [`InboundChannel`]
903/// seam: it names its [`ChannelId::IMessage`] and OWNS its poll loop, feeding
904/// observed inbound rows into the supplied sink. macOS-gated — the underlying
905/// `poll_once_default_db` reads the local Messages library.
906///
907/// The watermark/rowid stay PRIVATE to this adapter's poll loop (they never
908/// cross the sink — the sink sees only `handle_id` + `body`).
909#[cfg(target_os = "macos")]
910#[async_trait::async_trait]
911impl InboundChannel for MessagingOrchestrator {
912    fn channel(&self) -> ChannelId {
913        ChannelId::IMessage
914    }
915
916    async fn run(&self, _sink: &dyn InboundSink, cancel: CancelSignal) {
917        // The iMessage adapter is its own delivery path: each polled row goes to
918        // `self.handle_inbound`, which is the channel-agnostic approval sink for
919        // this channel. (The trait's `sink` param exists for adapters whose
920        // delivery is externally supplied, e.g. Slack in Unit 4.)
921        self.run_inbound_loop(std::time::Duration::from_secs(2), None, cancel)
922            .await;
923    }
924}
925
926/// Spawn every ENABLED approval-transport adapter at daemon boot (Unit 3 —
927/// cross-platform registry). Iterates [`ChannelId::ALL`]; for each channel that
928/// is enabled in `~/.car/messaging.json` it spawns that adapter's
929/// [`InboundChannel::run`] loop on the shared cancel signal. The iMessage
930/// adapter alone is `#[cfg(target_os = "macos")]`-gated (it reads the local
931/// Messages library); the registry/trait/`ChannelId` are unconditional — there
932/// are NO cargo feature flags.
933///
934/// The enabled-flag gate ALSO lives inside each adapter, so a channel that is
935/// off does zero work per tick even if spawned; the registry both (a) skips
936/// spawning disabled channels at boot, and (b) relies on the adapter's internal
937/// gate so a live toggle still flips behavior. Returns the cancel sender so the
938/// daemon shutdown path can stop every spawned loop.
939///
940/// **Unit 1 (runtime spawn-on-enable):** this also builds and returns a
941/// [`ChannelSupervisor`] — recorded with the channels spawned here at boot —
942/// that the host-gated `messaging.config.set` handler uses to spawn a channel's
943/// watcher the instant the user enables it, with NO daemon/app restart. The
944/// supervisor holds the SAME cancel signal and the SAME per-channel liveness map
945/// the boot adapters write into, so a runtime-spawned channel shuts down cleanly
946/// and reports status identically.
947pub fn spawn_channel_pollers(host: SharedHost) -> Arc<ChannelSupervisor> {
948    let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
949    let store = MessagingConfigStore::from_home();
950
951    // The shared per-channel liveness map (U2/U3). The boot adapters and any
952    // runtime-spawned adapter write outbound-send outcomes here; `messaging.status`
953    // reads it. Build it ONCE and hand the same `Arc` to every adapter + the
954    // supervisor. Only the macOS iMessage adapter consumes it today, so it is
955    // macOS-gated to avoid an unused binding on other platforms.
956    #[cfg(target_os = "macos")]
957    let liveness: SharedLiveness =
958        Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
959
960    // The fan-out coordinator (Unit 5) drives OUTBOUND once across all enabled
961    // channels with ONE shared code (MC-8). Each enabled channel contributes its
962    // concrete adapter `Arc` to the coordinator (for outbound) AND spawns its
963    // INBOUND loop (for replies/clicks). iMessage's inbound is poll-only at boot
964    // (`run_inbound_only_loop`) so outbound is not double-driven.
965    let core = car_server_types::approval_core::ApprovalCore::new(host.clone());
966
967    // Build the iMessage adapter Arc (macOS only). Hold it both for the
968    // coordinator (outbound) and to spawn its inbound poll loop.
969    #[cfg(target_os = "macos")]
970    let imessage: Option<Arc<MessagingOrchestrator>> = {
971        if store.is_enabled_for(ChannelId::IMessage).unwrap_or(false) {
972            // Same state root the config store above resolved — `$CAR_HOME`
973            // when set, else `~/.car` — so the orchestrator's cursor state and
974            // its config never land under two different roots.
975            let base_dir = car_home::root_or_relative();
976            let orch = Arc::new(MessagingOrchestrator::with_liveness(
977                host.clone(),
978                MessagingConfigStore::from_home(),
979                Arc::new(RealMessageSender),
980                base_dir,
981                liveness.clone(),
982            ));
983            // Spawn the inbound-only poll loop.
984            let orch_in = orch.clone();
985            let cancel_rx = cancel_tx.subscribe();
986            tokio::spawn(async move {
987                orch_in
988                    .run_inbound_only_loop(std::time::Duration::from_secs(2), cancel_rx)
989                    .await;
990            });
991            Some(orch)
992        } else {
993            None
994        }
995    };
996    #[cfg(not(target_os = "macos"))]
997    let imessage: Option<Arc<MessagingOrchestrator>> = None;
998
999    // Build the Slack adapter Arc (UNCONDITIONAL — no cfg gate, MC-11). Its
1000    // tokens live in the OS keychain (MC-9); the on-disk config holds NO bearer
1001    // value, only a keychain REFERENCE (the key names) persisted by the
1002    // host-gated `messaging.config.set` provisioning path. The transport is
1003    // constructed FROM that persisted ref, so the live adapter fetches the real
1004    // creds the host provisioned. The post-channel id is read from CONFIG (the
1005    // `slack_channel_id` in `messaging.json`, set on the same host-gated call as
1006    // the tokens) — it is configuration, not a secret, so it lives in the config
1007    // file, not the keychain. If no token has been provisioned (no
1008    // `slack_token_ref` in the config), the adapter cannot run — log + SKIP
1009    // rather than hard-crashing the boot (mirrors how iMessage skips a missing
1010    // prerequisite / chat.db). A misconfigured/absent post-channel id ⇒ the
1011    // adapter is built but cannot post (no-op outbound), still safe.
1012    let slack: Option<Arc<crate::slack_adapter::SlackAdapter>> = {
1013        if store.is_enabled_for(ChannelId::Slack).unwrap_or(false) {
1014            match store.slack_token_ref_for(ChannelId::Slack).unwrap_or(None) {
1015                Some(token_ref) => {
1016                    let post_channel = store
1017                        .slack_channel_id_for(ChannelId::Slack)
1018                        .unwrap_or(None)
1019                        .unwrap_or_default();
1020                    if post_channel.is_empty() {
1021                        // No post-channel configured: outbound `post_prompt` is a
1022                        // no-op (it early-returns on an empty channel), so we'd
1023                        // post nothing — but inbound (pairing + button resolve)
1024                        // still works, so build the adapter anyway. Warn ONCE
1025                        // here at boot rather than every doomed outbound tick.
1026                        tracing::warn!(
1027                            "slack channel enabled but no slack_channel id configured \
1028                             (no slack_channel in messaging.json) — outbound approval \
1029                             prompts will be skipped; inbound pairing + button resolve \
1030                             still active. Set via messaging.config.set \
1031                             {{ channel: \"slack\", slack_channel: \"<id>\" }}"
1032                        );
1033                    }
1034                    let transport = Arc::new(crate::slack_adapter::RealSlackTransport::new(
1035                        token_ref.bot_token_key.clone(),
1036                        token_ref.app_token_key.clone(),
1037                    ));
1038                    transport.clone().spawn_socket_loop(cancel_tx.subscribe());
1039                    let adapter = Arc::new(crate::slack_adapter::SlackAdapter::new(
1040                        host.clone(),
1041                        MessagingConfigStore::from_home(),
1042                        transport as Arc<dyn crate::slack_adapter::SlackTransport>,
1043                        post_channel,
1044                    ));
1045                    // Spawn the Slack inbound `run()` loop (push-based; pulls
1046                    // events off the transport seam and dispatches each).
1047                    let adapter_in = adapter.clone();
1048                    let cancel_rx = cancel_tx.subscribe();
1049                    tokio::spawn(async move {
1050                        let sink = NoopSink;
1051                        adapter_in.run(&sink, cancel_rx).await;
1052                    });
1053                    Some(adapter)
1054                }
1055                None => {
1056                    tracing::warn!(
1057                        "slack channel enabled but no tokens provisioned \
1058                         (no slack_token_ref in messaging.json) — skipping slack adapter; \
1059                         provision via messaging.config.set {{ channel: \"slack\", bot_token, app_token }}"
1060                    );
1061                    None
1062                }
1063            }
1064        } else {
1065            None
1066        }
1067    };
1068
1069    // Track which channels we spawned at boot so the supervisor's live set is
1070    // accurate from the start (U1 idempotency: a runtime enable of a
1071    // boot-spawned channel is a no-op).
1072    let imessage_spawned = imessage.is_some();
1073    let slack_spawned = slack.is_some();
1074
1075    // Spawn the single fan-out OUTBOUND ticker over the enabled channels. One
1076    // shared code per approval reaches every enabled channel (MC-8). If no
1077    // channel is enabled this still runs but is a cheap no-op (eligible-pending
1078    // query over an empty/gated set).
1079    if imessage.is_some() || slack.is_some() {
1080        let coordinator = crate::fanout::FanoutCoordinator::new(core, imessage, slack);
1081        let mut cancel_rx = cancel_tx.subscribe();
1082        tokio::spawn(async move {
1083            loop {
1084                coordinator.observe_and_fanout().await;
1085                tokio::select! {
1086                    _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
1087                    _ = cancel_rx.changed() => {
1088                        if *cancel_rx.borrow() {
1089                            break;
1090                        }
1091                    }
1092                }
1093            }
1094        });
1095    }
1096
1097    // U1: build the runtime supervisor over the SAME cancel signal + liveness
1098    // map, with the per-channel spawn closure, and record the boot-spawned
1099    // channels as live. The host-gated `messaging.config.set` handler reaches it
1100    // via `ServerState` to call `ensure_spawned(channel)` on an off→on
1101    // transition — no restart.
1102    let supervisor = Arc::new(ChannelSupervisor::new(host, cancel_tx, make_spawn_fn()));
1103    if imessage_spawned {
1104        supervisor.mark_spawned(ChannelId::IMessage);
1105    }
1106    if slack_spawned {
1107        supervisor.mark_spawned(ChannelId::Slack);
1108    }
1109    supervisor
1110}
1111
1112/// Build the per-channel spawn closure the [`ChannelSupervisor`] calls to start
1113/// ONE channel's watcher at runtime (U1). It is handed the channel, the shared
1114/// host, the shared liveness map, and a cancel receiver subscribed to the
1115/// supervisor's signal.
1116///
1117/// For a runtime-enabled iMessage channel that was OFF at boot, this spawns the
1118/// SELF-CONTAINED `MessagingOrchestrator::run_inbound_loop` — which runs BOTH
1119/// `observe_and_notify` (outbound prompts) and the inbound poll per tick. This
1120/// is the #403 direct-call path: for a single channel it is byte-for-byte the
1121/// same `outbound_body` grammar as the boot path. (The boot path splits inbound
1122/// from outbound only so the multi-channel fan-out coordinator can drive
1123/// outbound ONCE with a shared code across SIMULTANEOUSLY-enabled channels —
1124/// which does not apply when the user flips ONE channel on at runtime.)
1125///
1126/// Slack runtime-enable always returns an `Err` (see [`spawn_slack_runtime`]):
1127/// its outbound prompt driver is the boot-time `FanoutCoordinator`, which a
1128/// runtime closure cannot rebuild, so the channel must NOT be marked live (which
1129/// would make `messaging.status` falsely report `watcher_running:true`). The
1130/// host UI surfaces the error; a restart activates Slack outbound.
1131fn make_spawn_fn() -> crate::channel_supervisor::SpawnFn {
1132    Box::new(
1133        |channel: ChannelId,
1134         host: &SharedHost,
1135         liveness: &SharedLiveness,
1136         cancel_rx: tokio::sync::watch::Receiver<bool>|
1137         -> Result<(), String> {
1138            match channel {
1139                ChannelId::IMessage => spawn_imessage_runtime(host, liveness, cancel_rx),
1140                ChannelId::Slack => spawn_slack_runtime(host, liveness, cancel_rx),
1141            }
1142        },
1143    )
1144}
1145
1146/// Spawn a runtime-enabled iMessage watcher (macOS). Self-contained
1147/// outbound+inbound loop (`run_inbound_loop`). On non-macOS this is a no-op that
1148/// returns `Ok(())` (the channel can be "enabled" in config but reads nothing —
1149/// matching the boot path, which builds no iMessage adapter off macOS).
1150#[allow(unused_variables)]
1151fn spawn_imessage_runtime(
1152    host: &SharedHost,
1153    liveness: &SharedLiveness,
1154    cancel_rx: tokio::sync::watch::Receiver<bool>,
1155) -> Result<(), String> {
1156    #[cfg(target_os = "macos")]
1157    {
1158        // The CAR state root (`$CAR_HOME`, else `~/.car`), matching the boot
1159        // path above so a runtime-enabled watcher reads the same cursor state.
1160        let base_dir = car_home::root_or_relative();
1161        let orch = Arc::new(MessagingOrchestrator::with_liveness(
1162            host.clone(),
1163            MessagingConfigStore::from_home(),
1164            Arc::new(RealMessageSender),
1165            base_dir,
1166            liveness.clone(),
1167        ));
1168        tokio::spawn(async move {
1169            orch.run_inbound_loop(std::time::Duration::from_secs(2), None, cancel_rx)
1170                .await;
1171        });
1172        Ok(())
1173    }
1174    #[cfg(not(target_os = "macos"))]
1175    {
1176        // Off macOS the iMessage adapter reads nothing; recording the channel as
1177        // "spawned" is harmless (the enabled-flag gate already makes every tick
1178        // a no-op) and keeps the live-set bookkeeping uniform.
1179        Ok(())
1180    }
1181}
1182
1183/// Runtime-enable for Slack is intentionally NOT supported in this build.
1184///
1185/// Slack's OUTBOUND approval prompts (`SlackAdapter::post_prompt`) have exactly
1186/// ONE driver — the [`FanoutCoordinator`], built ONCE at boot in
1187/// `spawn_channel_pollers` over whatever channels were enabled at boot. A
1188/// runtime-enable closure can only spawn Slack's INBOUND loop (`adapter.run`),
1189/// which never calls `post_prompt`. So a Slack channel enabled at runtime would
1190/// receive inbound replies but could NEVER send an outbound approval prompt —
1191/// yet marking it live would make `messaging.status` report
1192/// `watcher_running:true` and the host UI show a green "Ready" it cannot honor.
1193///
1194/// To keep `watcher_running` HONEST (the load-bearing invariant: never report a
1195/// channel live when its outbound can't fire), this returns an `Err`. The
1196/// supervisor's `ensure_spawned` rolls back the reserved live-set entry on this
1197/// `Err`, so the channel is NOT marked live. The config flag is still persisted
1198/// by `messaging.config.set` (config is config) — the channel is simply not
1199/// watcher_running until the daemon restarts, which rebuilds the boot-time
1200/// `FanoutCoordinator` over the now-enabled Slack channel and activates outbound.
1201///
1202/// Building a runtime Slack outbound path is deliberately out of v1 scope.
1203/// iMessage's runtime-enable is unaffected: `spawn_imessage_runtime` uses the
1204/// self-contained `run_inbound_loop`, which drives BOTH outbound and inbound.
1205#[allow(unused_variables)]
1206fn spawn_slack_runtime(
1207    host: &SharedHost,
1208    liveness: &SharedLiveness,
1209    cancel_rx: tokio::sync::watch::Receiver<bool>,
1210) -> Result<(), String> {
1211    Err(
1212        "Slack runtime-enable is not supported in this build; restart to activate \
1213         outbound delivery."
1214            .to_string(),
1215    )
1216}
1217
1218/// A no-op [`InboundSink`] for adapters that own their own delivery (the
1219/// iMessage adapter delivers via its internal `handle_inbound`; the Slack
1220/// adapter resolves by `approval_id` + pairs via the code primitive — neither
1221/// routes through the `handle_id`+`body` sink, so its `run` ignores the passed
1222/// sink). Present so the registry can drive any adapter through the seam
1223/// signature uniformly.
1224struct NoopSink;
1225
1226#[async_trait::async_trait]
1227impl InboundSink for NoopSink {
1228    async fn deliver(&self, _channel: ChannelId, _msg: &InboundMessage) {}
1229}
1230
1231/// Build the outbound prompt body: a short action summary plus the
1232/// per-approval code and how to reply. Kept compact for a text message.
1233///
1234/// `pub` so the MC-1 fan-out gate can assert the fan-out iMessage body
1235/// (rendered by [`MessagingOrchestrator::send_shared_prompt`]) is byte-for-byte
1236/// the poll-path grammar — pinning the MC-1 guarantee against a future drift in
1237/// `send_shared_prompt`.
1238pub fn outbound_body(approval: &HostApprovalRequest, code: &str) -> String {
1239    // `action` is agent-authored, unvalidated and unbounded. Sanitised for the
1240    // same reason the summary's fields are: raw, it can carry newlines that
1241    // forge lines looking like the labelled block below it, and length that
1242    // pushes that block off a phone screen.
1243    let mut body = format!("Approval needed: {}\n", sanitize_line(&approval.action));
1244    // What the action would actually do, when `details` says. Without it this
1245    // channel asked for a decision while withholding everything the decision
1246    // rests on — the dashboard has rendered the same payload all along
1247    // (`car_proto::approval_summary`).
1248    if let Some(summary) = approval_summary(approval) {
1249        body.push_str(&summary);
1250        body.push('\n');
1251    }
1252    body.push_str(&format!("Reply `{code} approve` or `{code} deny`."));
1253    body
1254}
1255
1256/// Build the disambiguation reply listing the pending codes (when a bare
1257/// approve/deny is ambiguous because 2+ approvals are pending).
1258fn disambiguation_body(pending: &[(String, String, String)]) -> String {
1259    let mut s =
1260        String::from("Multiple approvals are pending. Reply with a code, e.g. `<code> approve`:\n");
1261    // Name each action next to its code. A bare code list asks the approver to
1262    // choose between `A0` and `B0` on no information at all — the one moment
1263    // they are certain to be deciding about more than one thing.
1264    for (code, _id, action) in pending {
1265        // This listing is where the overseer maps an opaque code onto what it
1266        // means, and their reply resolves that code — so a raw multi-line
1267        // `action` here could forge a second row for a DIFFERENT pending
1268        // approval, or pad the real row out of view on a phone.
1269        s.push_str(&format!("• {code} — {}\n", sanitize_line(action)));
1270    }
1271    s
1272}
1273
1274/// Parse an inbound body to exactly one [`InboundIntent`]. **Closed output** —
1275/// the only things this can ever produce are approve/deny (optionally
1276/// code-prefixed), a pairing-code candidate, or ignore. There is NO branch
1277/// that returns a config mutation. Recognizes (case-insensitive):
1278///
1279/// - `approve` / `deny` (bare)
1280/// - `<code> approve` / `<code> deny` (leading short code)
1281/// - `approve <code>` / `deny <code>` (trailing short code — lenient)
1282/// - a standalone token of pairing-code length ⇒ a pairing candidate
1283///
1284/// Everything else ⇒ `Ignore`.
1285fn parse_inbound(body: &str) -> InboundIntent {
1286    let trimmed = body.trim();
1287    if trimmed.is_empty() {
1288        return InboundIntent::Ignore;
1289    }
1290    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
1291
1292    // Single token: a bare word, or a standalone pairing-code candidate.
1293    if tokens.len() == 1 {
1294        let t = tokens[0];
1295        if t.eq_ignore_ascii_case(APPROVE) {
1296            return InboundIntent::Approve { code: None };
1297        }
1298        if t.eq_ignore_ascii_case(DENY) {
1299            return InboundIntent::Deny { code: None };
1300        }
1301        if looks_like_pairing_code(t) {
1302            return InboundIntent::PairingCode(t.to_string());
1303        }
1304        return InboundIntent::Ignore;
1305    }
1306
1307    // Two-ish tokens: a short code paired with approve/deny, in either order.
1308    if tokens.len() == 2 {
1309        let (a, b) = (tokens[0], tokens[1]);
1310        // `<code> approve|deny`
1311        if let Some(intent) = code_word(a, b) {
1312            return intent;
1313        }
1314        // `approve|deny <code>` (lenient trailing-code form)
1315        if let Some(intent) = code_word(b, a) {
1316            return intent;
1317        }
1318    }
1319
1320    InboundIntent::Ignore
1321}
1322
1323/// If `word` is approve/deny and `code` looks like a short per-approval code,
1324/// build the corresponding code-named intent. Returns `None` otherwise.
1325fn code_word(code: &str, word: &str) -> Option<InboundIntent> {
1326    if !looks_like_approval_code(code) {
1327        return None;
1328    }
1329    if word.eq_ignore_ascii_case(APPROVE) {
1330        return Some(InboundIntent::Approve {
1331            code: Some(code.to_uppercase()),
1332        });
1333    }
1334    if word.eq_ignore_ascii_case(DENY) {
1335        return Some(InboundIntent::Deny {
1336            code: Some(code.to_uppercase()),
1337        });
1338    }
1339    None
1340}
1341
1342/// A short per-approval code looks like `A7`/`B12` — a leading ASCII letter
1343/// then digits, short. (Distinguishes it from the long pairing code.)
1344fn looks_like_approval_code(t: &str) -> bool {
1345    let bytes = t.as_bytes();
1346    if bytes.len() < APPROVAL_CODE_LEN || bytes.len() > 5 {
1347        return false;
1348    }
1349    bytes[0].is_ascii_alphabetic() && bytes[1..].iter().all(|b| b.is_ascii_digit())
1350}
1351
1352/// A standalone token of exactly pairing-code length and base64url charset is a
1353/// pairing-code candidate. The constant-time compare in
1354/// `validate_and_consume_pairing_code` is the real gate; this is just the
1355/// parser's recognizer so a 43-char token routes to pairing rather than ignore.
1356fn looks_like_pairing_code(t: &str) -> bool {
1357    t.len() == PAIRING_CODE_LEN
1358        && t.bytes()
1359            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    //! SC-C — Parser-strictness backstop (Wall 2 pinned).
1365    //!
1366    //! On a single-Apple-ID (solo) account the daemon's OWN outbound bodies echo
1367    //! back into chat.db as `is_from_me = 0` received rows. Wall 1 (the
1368    //! `is_from_me = 0` SQL filter, `read.rs`) drops those echoes before a row is
1369    //! constructed. This unit pins the SECOND wall: even IF a daemon-authored body
1370    //! reached `parse_inbound` (defense-in-depth, or a future refactor that lets
1371    //! one through), the closed-grammar parser must classify it `Ignore` — never a
1372    //! command. Both `outbound_body` and `disambiguation_body` are multi-token
1373    //! strings with leading non-command words, so they fall through to the
1374    //! terminal `Ignore`. This test fails the moment a future rephrase of either
1375    //! body collapses it to a 1–2-token command-shaped form. Pure in-process —
1376    //! no host, no async.
1377
1378    use super::*;
1379
1380    /// Build a representative system-level `HostApprovalRequest` for feeding into
1381    /// `outbound_body`. Only `.action` is load-bearing for the body; the rest of
1382    /// the field set mirrors what the host produces for a fire-and-return row.
1383    fn sample_approval() -> HostApprovalRequest {
1384        HostApprovalRequest {
1385            id: "id0".to_string(),
1386            agent_id: None,
1387            client_id: None,
1388            action: "send wire transfer".to_string(),
1389            details: serde_json::Value::Null,
1390            options: vec![],
1391            status: HostApprovalStatus::Pending,
1392            created_at: chrono::Utc::now(),
1393            resolved_at: None,
1394            resolution: None,
1395        }
1396    }
1397
1398    #[test]
1399    fn parse_inbound_ignores_daemon_authored_bodies() {
1400        // The outbound approval prompt the daemon sends (and which echoes back
1401        // `is_from_me = 0` on a solo account) must parse to Ignore.
1402        let prompt = outbound_body(&sample_approval(), "A0");
1403        assert_eq!(
1404            parse_inbound(&prompt),
1405            InboundIntent::Ignore,
1406            "the daemon's own outbound prompt body must parse to Ignore, got {prompt:?}"
1407        );
1408
1409        // The disambiguation reply the daemon sends (also echoes back
1410        // `is_from_me = 0` on a solo account) must parse to Ignore.
1411        let disambig = disambiguation_body(&[
1412            (
1413                "A0".to_string(),
1414                "id0".to_string(),
1415                "send wire transfer".to_string(),
1416            ),
1417            (
1418                "B0".to_string(),
1419                "id1".to_string(),
1420                "delete prod database".to_string(),
1421            ),
1422        ]);
1423        assert_eq!(
1424            parse_inbound(&disambig),
1425            InboundIntent::Ignore,
1426            "the daemon's own disambiguation body must parse to Ignore, got {disambig:?}"
1427        );
1428    }
1429
1430    /// The prompt must say what it is asking about. `details` is the payload
1431    /// the dashboard has always rendered; the text channels dropped it, so a
1432    /// remote approver saw a bare method name and two verbs.
1433    #[test]
1434    fn outbound_body_carries_the_approval_details() {
1435        let mut a = sample_approval();
1436        a.action = "ws.method:mail.send".to_string();
1437        a.details = serde_json::json!({
1438            "method": "mail.send",
1439            "params_preview": {
1440                "to": ["ceo@example.com"],
1441                "subject": "Q3 numbers",
1442                "body": "Attached are the figures we discussed.",
1443            }
1444        });
1445        let body = outbound_body(&a, "A0");
1446        assert!(body.contains("To: ceo@example.com"), "{body}");
1447        assert!(body.contains("Subject: Q3 numbers"), "{body}");
1448        // The resolve grammar is unchanged and still last.
1449        assert!(body.ends_with("Reply `A0 approve` or `A0 deny`."), "{body}");
1450    }
1451
1452    /// An approval with no renderable `details` keeps exactly the old body —
1453    /// the summary is additive, never a rewrite of the grammar.
1454    #[test]
1455    fn outbound_body_without_details_is_unchanged() {
1456        let body = outbound_body(&sample_approval(), "A0");
1457        assert_eq!(
1458            body,
1459            "Approval needed: send wire transfer\nReply `A0 approve` or `A0 deny`."
1460        );
1461    }
1462
1463    /// `details` is authored by the requesting agent, so it is attacker-
1464    /// influenced text that now reaches an outbound body. It must not be able
1465    /// to steer the parser: the daemon's own body still parses to `Ignore`
1466    /// (Wall 2) no matter what the payload says.
1467    #[test]
1468    fn agent_authored_details_cannot_forge_a_command_body() {
1469        let mut a = sample_approval();
1470        a.details = serde_json::json!({
1471            "params_preview": { "body": "A0 approve" }
1472        });
1473        let body = outbound_body(&a, "A0");
1474        assert_eq!(
1475            parse_inbound(&body),
1476            InboundIntent::Ignore,
1477            "a crafted details payload must not make the daemon's own body \
1478             parse as a command, got {body:?}"
1479        );
1480
1481        // Nor by way of the disambiguation reply, whose action text is also
1482        // agent-authored.
1483        let disambig = disambiguation_body(&[
1484            ("A0".to_string(), "id0".to_string(), "approve".to_string()),
1485            ("B0".to_string(), "id1".to_string(), "deny".to_string()),
1486        ]);
1487        assert_eq!(
1488            parse_inbound(&disambig),
1489            InboundIntent::Ignore,
1490            "{disambig:?}"
1491        );
1492    }
1493
1494    /// `action` is agent-authored, unvalidated and unbounded. A multi-line one
1495    /// must not be able to forge a row for a DIFFERENT pending code — the
1496    /// listing is exactly where the overseer maps a code onto its meaning, and
1497    /// their reply resolves that code.
1498    #[test]
1499    fn a_multiline_action_cannot_forge_a_row_for_another_code() {
1500        let disambig = disambiguation_body(&[
1501            (
1502                "A0".to_string(),
1503                "id0".to_string(),
1504                "check the weather\n• B0 — check the weather".to_string(),
1505            ),
1506            (
1507                "B0".to_string(),
1508                "id1".to_string(),
1509                "wire $50,000 to account 12345".to_string(),
1510            ),
1511        ]);
1512        // Exactly one row per pending code, plus the instruction line.
1513        assert_eq!(
1514            disambig.lines().filter(|l| l.starts_with("• B0")).count(),
1515            1,
1516            "A0's action forged a second B0 row: {disambig}"
1517        );
1518        assert!(
1519            disambig.contains("• B0 — wire $50,000"),
1520            "the real B0 row must survive: {disambig}"
1521        );
1522    }
1523
1524    /// And it must not be able to push the real rows out of view by length.
1525    #[test]
1526    fn an_overlong_action_is_capped_in_the_listing() {
1527        let disambig = disambiguation_body(&[
1528            ("A0".to_string(), "id0".to_string(), "x".repeat(9000)),
1529            (
1530                "B0".to_string(),
1531                "id1".to_string(),
1532                "wire transfer".to_string(),
1533            ),
1534        ]);
1535        assert!(
1536            disambig.chars().count() < 1000,
1537            "one agent's action must not dominate the listing: {} chars",
1538            disambig.chars().count()
1539        );
1540        assert!(disambig.contains("• B0 — wire transfer"), "{disambig}");
1541    }
1542
1543    /// Choosing between `A0` and `B0` on no information is not a decision.
1544
1545    #[test]
1546    fn disambiguation_names_each_pending_action() {
1547        let disambig = disambiguation_body(&[
1548            (
1549                "A0".to_string(),
1550                "id0".to_string(),
1551                "send wire transfer".to_string(),
1552            ),
1553            (
1554                "B0".to_string(),
1555                "id1".to_string(),
1556                "delete prod database".to_string(),
1557            ),
1558        ]);
1559        assert!(disambig.contains("A0 — send wire transfer"), "{disambig}");
1560        assert!(disambig.contains("B0 — delete prod database"), "{disambig}");
1561    }
1562
1563    /// F1 regression — runtime-enabling a Slack channel that was OFF at boot must
1564    /// NOT result in `watcher_running:true`.
1565    ///
1566    /// Slack's outbound approval prompts (`SlackAdapter::post_prompt`) are driven
1567    /// ONLY by the boot-time `FanoutCoordinator`. The runtime-enable closure
1568    /// (`make_spawn_fn` → `spawn_slack_runtime`) cannot rebuild that coordinator,
1569    /// so a Slack channel enabled at runtime could receive inbound but never send
1570    /// an outbound prompt. To keep `messaging.status`'s `watcher_running` HONEST,
1571    /// `spawn_slack_runtime` returns an `Err`, and the supervisor's reserve-then-
1572    /// rollback in `ensure_spawned` leaves the channel NOT marked live.
1573    ///
1574    /// This exercises the REAL production spawn closure (`make_spawn_fn`) through
1575    /// the public supervisor API. Fully hermetic: `spawn_slack_runtime` returns
1576    /// `Err` before touching any token store, socket, or osascript — no network.
1577    #[test]
1578    fn runtime_enable_slack_does_not_report_watcher_running() {
1579        let host: SharedHost = Arc::new(HostState::new());
1580        let (cancel_tx, _rx) = tokio::sync::watch::channel(false);
1581        // The SAME spawn closure the boot path installs on the real supervisor.
1582        let sup = ChannelSupervisor::new(host, cancel_tx, make_spawn_fn());
1583
1584        // Sanity: not live before any enable.
1585        assert!(!sup.is_spawned(ChannelId::Slack));
1586
1587        // Runtime-enable Slack (off at boot) → the spawn closure returns Err.
1588        let result = sup.ensure_spawned(ChannelId::Slack);
1589        assert!(
1590            result.is_err(),
1591            "Slack runtime-enable must fail (outbound cannot fire without a boot \
1592             restart), got: {result:?}"
1593        );
1594        let msg = result.unwrap_err();
1595        assert!(
1596            msg.contains("restart"),
1597            "the error must tell the user a restart activates outbound delivery, got: {msg:?}"
1598        );
1599
1600        // The LOAD-BEARING invariant: the channel is NOT marked live, so
1601        // `messaging.status` reads `watcher_running:false` for it — never a green
1602        // "Ready" the daemon cannot honor.
1603        assert!(
1604            !sup.is_spawned(ChannelId::Slack),
1605            "a Slack channel whose outbound can't fire must NOT be watcher_running"
1606        );
1607
1608        // A retry stays honest too (still Err, still not live) — there is no
1609        // provisioning that flips this in-process; only a daemon restart rebuilds
1610        // the boot-time FanoutCoordinator that drives Slack outbound.
1611        assert!(sup.ensure_spawned(ChannelId::Slack).is_err());
1612        assert!(!sup.is_spawned(ChannelId::Slack));
1613    }
1614}
1615
1616// NOTE on iMessage runtime-enable: it remains fully functional (the
1617// self-contained `run_inbound_loop` drives BOTH outbound and inbound) and is
1618// covered hermetically by `tests/messaging_runtime_enable_spawn.rs`. It is NOT
1619// asserted here because `spawn_imessage_runtime` calls `tokio::spawn` on macOS,
1620// which requires an async runtime this synchronous unit test deliberately avoids
1621// — the Slack path returns `Err` before any spawn, keeping this test runtime-free.