Skip to main content

polyc_rpc_client/
lib.rs

1//! Thin connectrpc client over the generated
2//! [`polyc_proto::proto::polychrome::agent::v1::AgentServiceClient`].
3//!
4//! Both the operator CLI (`polychrome send`) and the reference-edge receiver dial the
5//! external-facing `AgentService` with the same durable-receive dance: send
6//! one [`AgentRequest`], obtain its receipt, then drain the attached
7//! [`AgentResponse`](polyc_proto::proto::polychrome::agent::v1::AgentResponse)
8//! stream, and render each non-empty content block to user-visible text. This
9//! crate is the single home for that logic so the two call sites can't drift.
10//!
11//! RPC-client concerns deliberately live here rather than in
12//! `polyc-agent` (the turn-loop crate, the wrong layer for a transport
13//! client).
14//!
15//! Buffered and incremental response projections build on the same receipt
16//! and attachment client.
17
18#![forbid(unsafe_code)]
19#![warn(missing_docs)]
20
21pub mod edge;
22pub use edge::{
23    ClaimedNamespace, ClaimedNamespaceError, EdgeAdapter, IngressDirective, IngressIdentity,
24    IngressIdentityError, MAX_CLAIMED_NAMESPACE_BYTES, Priority, build_attribution,
25};
26
27pub mod edge_credentials;
28
29/// Test-only access to this crate's envelope builder, behind the `test-util`
30/// feature.
31///
32/// The control plane's ingest-gate tests must present envelopes an edge would
33/// really send. Re-deriving the signing contract in a fixture would let the
34/// two drift — the tests would keep passing against their own copy while real
35/// edges broke — so they mint envelopes through the SAME
36/// [`build_asserted_attribution`] production dials go through.
37///
38/// Mirrors `polyc-passkey`/`polyc-web-session`'s `test-util` shape. Never
39/// compiled into a release binary: it is reached only via a dev-dependency.
40#[cfg(feature = "test-util")]
41pub mod test_util {
42    use super::{
43        AssertedAttribution, Attribution, ClaimedNamespace, EdgeCredentials, IngressIdentity,
44        Message, ParticipantMessage, build_asserted_attribution, build_classify_attribution,
45    };
46    use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
47
48    /// Build and sign a real participation-gate envelope.
49    ///
50    /// The exact envelope [`crate::AgentDialer::should_respond`] puts on the
51    /// wire, so a control-plane test cannot drift from what the edge sends.
52    #[must_use]
53    pub fn signed_classify_envelope(
54        creds: &EdgeCredentials,
55        conversation_id: &str,
56        source_identity: &IngressIdentity,
57        namespace: &ClaimedNamespace,
58        transcript: &[ParticipantMessage],
59        bot_name: &str,
60        surface: &str,
61    ) -> AssertedAttribution {
62        build_classify_attribution(
63            creds,
64            conversation_id,
65            source_identity,
66            namespace,
67            transcript,
68            bot_name,
69            surface,
70        )
71    }
72
73    /// Build and sign a real [`AssertedAttribution`] for `messages`.
74    ///
75    /// Binds it to `conversation_id`/`exec_id` and asserts
76    /// `caller`/`participants` — the exact envelope
77    /// [`crate::AgentDialer::with_credentials`] puts on the wire for the same
78    /// turn.
79    #[must_use]
80    #[allow(
81        clippy::too_many_arguments,
82        reason = "mirrors `build_asserted_attribution`'s parameter list on \
83                  purpose: a fixture that could omit one would stop testing \
84                  the envelope production dials really send"
85    )]
86    pub fn signed_envelope(
87        creds: &EdgeCredentials,
88        conversation_id: &str,
89        exec_id: &str,
90        source_identity: &IngressIdentity,
91        namespace: &ClaimedNamespace,
92        messages: &[Message],
93        caller: Option<ExternalIdentity>,
94        participants: Vec<ExternalIdentity>,
95    ) -> AssertedAttribution {
96        build_asserted_attribution(
97            creds,
98            conversation_id,
99            exec_id,
100            source_identity,
101            namespace,
102            messages,
103            &Attribution {
104                caller,
105                participants,
106            },
107        )
108    }
109}
110pub use edge_credentials::{
111    CredentialError, EdgeCredentials, EdgeCredentialsError, edge_credentials_from_env_or_fail,
112};
113
114/// Re-exported so the thin edges (slack/telegram/discord/email/trigger/a2a)
115/// can wrap their two secret config fields (the transport bearer, the
116/// ed25519 signing key hex) without taking a direct `polyc-crypto`
117/// dependency of their own — this crate already depends on it (signing an
118/// [`edge_credentials::EdgeCredentials`]'s envelope), and every edge already
119/// depends on this crate.
120pub use polyc_crypto::sensitive::Sensitive;
121
122/// Reads an optional wrapped secret, treating an empty string as unset.
123///
124/// Every thin edge (slack/telegram/trigger/...) reads its soft-required
125/// secrets — a signing secret, a bot token — the same way: exposed through
126/// [`Sensitive::expose`], then folded to `None` when the exposed value is
127/// `""` so a blank env var or CLI placeholder reads as "not configured"
128/// exactly like a genuinely absent field. This is the one sanctioned place to
129/// perform that emptiness peek at an edge read site — call sites that only
130/// need the raw (possibly empty) value keep calling `.expose()` directly.
131#[must_use]
132pub fn expose_nonempty(secret: Option<&Sensitive<String>>) -> Option<&str> {
133    secret
134        .map(Sensitive::expose)
135        .map(String::as_str)
136        .filter(|s| !s.is_empty())
137}
138
139/// Test-support assertion for edge crates' redaction tests: asserts `value`'s
140/// `Debug` output carries the `Sensitive` redaction marker and contains none
141/// of `must_not_contain`'s raw secret values.
142///
143/// Every edge (a2a/slack/telegram/discord/email/trigger) re-proves the same
144/// `Sensitive` contract against its own `Config`/`Args` struct — this
145/// collapses each of those near-identical bodies to a one-line call.
146/// `#[doc(hidden)]` keeps it out of the crate's public rustdoc surface (it
147/// exists for this workspace's tests, not downstream consumers of the
148/// published API), while the doc comment above still satisfies
149/// `missing_docs`.
150///
151/// # Panics
152///
153/// Panics (via `assert!`) if `value`'s `Debug` output doesn't contain
154/// `Sensitive(<redacted>)`, or if it contains any of `must_not_contain`.
155#[doc(hidden)]
156pub fn assert_redacted(value: &impl core::fmt::Debug, must_not_contain: &[&str]) {
157    let debug = format!("{value:?}");
158    assert!(
159        debug.contains("Sensitive(<redacted>)"),
160        "expected a Sensitive redaction marker in Debug output, got {debug}"
161    );
162    for raw in must_not_contain {
163        assert!(
164            !debug.contains(raw),
165            "Debug output leaked a raw secret value {raw:?}: {debug}"
166        );
167    }
168}
169
170use std::sync::Arc;
171
172use chrono::{DateTime, Datelike as _, FixedOffset, Utc};
173use connectrpc::client::{ClientConfig, HttpClient};
174use futures::Stream;
175use polyc_agent::text_message;
176use polyc_proto::proto::polychrome::agent::v1::{
177    AgentEnd, AgentRequest, AgentServiceClient, AgentStart,
178    ApprovalPreview as WireAgentApprovalPreview,
179    ApprovalPreviewFire as WireAgentApprovalPreviewFire, AssertedAttribution, AttachIngressRequest,
180    ClassifyRequest, CompactionReason as WireCompactionReason, ContextCompacted,
181    IngressDirective as WireIngressDirective, IngressReceipt as WireIngressReceipt,
182    IngressSourceIdentity as WireIngressSourceIdentity, InterruptRequest, Message,
183    ParticipantMessage, PendingApproval as WireAgentPendingApproval,
184    PendingQuestion as WireAgentPendingQuestion, QuestionOption as WireAgentQuestionOption,
185    TurnFailureKind as WireTurnFailureKind, Verdict, agent_response, content, tool_call_content,
186};
187use polyc_proto::proto::polychrome::approval::v1::{
188    ApprovalPreview as WireListApprovalPreview, ApprovalPreviewFire as WireListApprovalPreviewFire,
189    ApprovalResponseRequest, ApprovalServiceClient, ListApprovalRecoveryRequest,
190    ListPendingRequest, PendingApprovalEntry, ReadPreview as WireListReadPreview,
191    RecordedApprovalDecision as WireRecordedApprovalDecision, RecoverableApprovalEntry,
192    RepromptRequired as WireRepromptRequired, SearchHitPreview as WireSearchHitPreview,
193    SearchPreview as WireSearchPreview, read_preview as wire_list_read_preview,
194    recoverable_approval_entry,
195};
196use polyc_proto::proto::polychrome::credential::v1::{
197    AddCredentialKeyRequest, CredentialKeySummary as WireCredentialKeySummary,
198    CredentialKeyVerifier as WireCredentialKeyVerifier, CredentialServiceClient,
199    CredentialSummary as WireCredentialSummary, EnrollCredentialRequest,
200    KeyState as WireCredentialKeyState, ListCredentialsRequest, RetireCredentialKeyRequest,
201    RevokeCredentialRequest,
202};
203use polyc_proto::proto::polychrome::ops::v1::{
204    AckRequest, DecideRequest, NotificationServiceClient, OperatorMailboxServiceClient,
205    PollPendingRequest, SubscribeRequest, decide_reply, ops_action_view, upgrade_outcome,
206};
207use polyc_proto::proto::polychrome::persona::v1::{
208    AdminInviteRequest, AttestVerifiedEmailOutcome, AttestVerifiedEmailRequest, AutoLinkOutcome,
209    AutoLinkRequest, CompleteLinkRequest, DescribeRequest, LinkOutcome, PersonaServiceClient,
210    RebuildUsageRollupsRequest, SetIncognitoRequest, StartDeepLinkRequest, StartLinkRequest,
211};
212use polyc_proto::proto::polychrome::question::v1::{
213    Decline as WireDecline, ListPendingQuestionsRequest, PendingQuestionEntry,
214    QuestionAnswerRequest, QuestionOptionEntry, QuestionServiceClient,
215    SelectOption as WireSelectOption, question_answer_request::Answer as WireAnswer,
216};
217use polyc_proto::proto::polychrome::routine::v1::{FireRoutineRequest, RoutineServiceClient};
218use polyc_proto::proto::polychrome::task::v1::{
219    AgentTask as WireAgentTask, AgentTaskOwnership as WireAgentTaskOwnership,
220    AgentTaskServiceClient, AgentTaskState as WireAgentTaskState, CancelAgentTaskRequest,
221    ClaimAgentTaskRequest, CreateAgentTaskRequest, GetAgentTaskRequest, ListAgentTasksRequest,
222    RenewAgentTaskClaimRequest, TransitionAgentTaskRequest,
223};
224
225/// Errors an agent dial can produce.
226#[derive(Debug, thiserror::Error)]
227pub enum DialError {
228    /// Could not parse the configured agent address as a URI.
229    #[error("invalid agent address {addr:?}: {source}")]
230    InvalidAddress {
231        /// The address string that failed to parse.
232        addr: String,
233        /// The underlying URI parse error.
234        #[source]
235        source: http::uri::InvalidUri,
236    },
237    /// Building the TLS client for an `https://` endpoint failed (e.g. no
238    /// process-default crypto provider). The dial fails closed rather than
239    /// silently downgrading to plaintext.
240    #[error("tls setup failed for agent address: {0}")]
241    Tls(String),
242    /// Connect-level error from the `AgentService` stream.
243    #[error(transparent)]
244    Connect(#[from] connectrpc::ConnectError),
245    /// A bearer credential could not be encoded as an HTTP header value
246    /// (e.g. contains a newline or non-ASCII byte). Produced by
247    /// [`AgentDialer::with_credentials`], `PersonaDialer::new_admin`,
248    /// `CredentialDialer::new_admin`, and `RoutineDialer::fire_routine`'s
249    /// admin path. Fails the dial closed rather than silently sending the
250    /// turn unauthenticated.
251    #[error("bearer credential is not a valid header value: {0}")]
252    InvalidBearer(String),
253    /// A turn ingress was attempted with an unauthenticated dialer.
254    ///
255    /// Stable source identity rides only inside the signed edge envelope, so
256    /// sending without credentials would necessarily drop the identity and is
257    /// refused before transport I/O.
258    #[error("turn ingress requires edge credentials so its source identity is signed")]
259    MissingIngressCredentials,
260    /// The server returned a success response that cannot prove the requested
261    /// source event was durably received.
262    #[error("invalid durable ingress receipt: {0}")]
263    InvalidIngressReceipt(&'static str),
264    /// The recovery service returned an entry without one of its required
265    /// states.
266    #[error("invalid approval recovery entry: {0}")]
267    InvalidApprovalRecovery(&'static str),
268}
269
270impl DialError {
271    /// The Connect error code, when this is a transport-level Connect error
272    /// (`None` for local address/TLS-setup/credential failures).
273    #[must_use]
274    pub const fn code(&self) -> Option<connectrpc::ErrorCode> {
275        match self {
276            Self::Connect(e) => Some(e.code),
277            Self::InvalidAddress { .. }
278            | Self::Tls(_)
279            | Self::InvalidBearer(_)
280            | Self::MissingIngressCredentials
281            | Self::InvalidIngressReceipt(_)
282            | Self::InvalidApprovalRecovery(_) => None,
283        }
284    }
285
286    /// Whether retrying the call could plausibly succeed. Only transient
287    /// transport conditions are retryable; terminal codes
288    /// (`InvalidArgument`, `Unauthenticated`, `NotFound`, …) and local
289    /// address/TLS failures are not. Edges use this to decide whether to ask
290    /// the source platform to redeliver (retryable) or to drop / 4xx
291    /// (terminal — redelivery would loop forever).
292    #[must_use]
293    pub const fn is_retryable(&self) -> bool {
294        matches!(
295            self.code(),
296            Some(
297                connectrpc::ErrorCode::Unavailable
298                    | connectrpc::ErrorCode::DeadlineExceeded
299                    | connectrpc::ErrorCode::ResourceExhausted
300                    | connectrpc::ErrorCode::Aborted
301            )
302        )
303    }
304
305    /// Whether this error is a `DeadlineExceeded` — the call's deadline
306    /// elapsed rather than anything failing.
307    ///
308    /// A long-lived stream that is idle by design ([`NotificationDialer::subscribe`])
309    /// ends this way on every cycle, so its caller reports the recycle as routine
310    /// instead of as a failure. Callers of short unary RPCs should keep treating
311    /// it as an error.
312    ///
313    /// The subscribe stream carries a deadline it cannot be exempted from
314    /// where it is currently mounted, so reaching that deadline is the normal
315    /// cycle, not a failure — reporting it as one buries the case where the
316    /// notifier is genuinely broken (#1794).
317    #[must_use]
318    pub const fn is_deadline_exceeded(&self) -> bool {
319        matches!(self.code(), Some(connectrpc::ErrorCode::DeadlineExceeded))
320    }
321
322    /// Whether a signed human-action capability is absent, expired, or no
323    /// longer matches the durable occurrence it names.
324    ///
325    /// Approval edges use this to retire a stale card instead of inviting a
326    /// deterministic retry. The server reports malformed/expired capability
327    /// tokens as `Unauthenticated` and an occurrence that can no longer be
328    /// answered as `FailedPrecondition`.
329    #[must_use]
330    pub const fn is_stale_capability(&self) -> bool {
331        matches!(
332            self.code(),
333            Some(
334                connectrpc::ErrorCode::Unauthenticated | connectrpc::ErrorCode::FailedPrecondition
335            )
336        )
337    }
338}
339
340/// Default per-call deadline for an agent turn.
341///
342/// Emitted as `Connect-Timeout-Ms` on every dial. Turns can be long (tool
343/// loops, slow providers), so this is generous; edges add their own outer
344/// `tokio::time::timeout` for defence.
345///
346/// It must stay ABOVE the control plane's own stream cap, because the control
347/// plane HONORS a client deadline shorter than that cap. A caller deadline at
348/// or below the cap cuts the response stream at the same instant the turn's
349/// own budget expires, so the edge reports a transport failure for a turn that
350/// was about to end with a precise one. The control plane's
351/// `grpc::tests::turn_deadline_chain_leaves_room_after_a_cold_start` asserts
352/// that ordering against this constant, which is why it is public.
353pub const AGENT_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(4);
354
355/// Default per-call deadline for the short control-plane RPCs (approval
356/// responses, persona lookups/ceremonies). These never run a turn.
357const CONTROL_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
358
359/// Bound the TCP connect phase of every control-plane dial so a dead or
360/// terminating peer fails fast (surfaced as `Unavailable`) instead of
361/// blackholing the SYN for the kernel `tcp_syn_retries` (~130s). Bounds only
362/// `connect(2)`; DNS and TLS handshake are covered by the per-call deadline.
363/// Mirrors the `CONNECT_TIMEOUT` const in the llm-vertex / llm-openai clients.
364const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
365
366/// Build the Connect HTTP transport for `uri`, honoring its scheme: `https`
367/// dials over TLS (OS trust store); anything else (incl. a scheme-less
368/// `host:port`) uses plaintext. An `https` address is **never** silently
369/// downgraded — a TLS build failure surfaces as [`DialError::Tls`].
370///
371/// No `pool_idle_timeout` is configured here (`#757` follow-up): this
372/// `HttpClient` pools connections through hyper's legacy client, whose
373/// `Client::builder` already defaults `pool_idle_timeout` to 90 seconds (see
374/// `hyper_util::client::legacy::client::Config::default`) — comfortably under
375/// any realistic gap between calls, so a connection genuinely idle in the pool
376/// is already reaped rather than blackholed. connectrpc 0.8.1's
377/// `HttpClientBuilder` doesn't expose a way to change this default anyway
378/// (verified against its source — there is no `pool_idle_timeout` method).
379fn http_client_for(uri: &http::Uri) -> Result<HttpClient, DialError> {
380    if uri.scheme_str() == Some("https") {
381        use rustls_platform_verifier::ConfigVerifierExt;
382        let tls = rustls::ClientConfig::with_platform_verifier()
383            .map_err(|e| DialError::Tls(e.to_string()))?;
384        Ok(HttpClient::builder()
385            .connect_timeout(CONNECT_TIMEOUT)
386            .with_tls(std::sync::Arc::new(tls)))
387    } else {
388        Ok(HttpClient::builder()
389            .connect_timeout(CONNECT_TIMEOUT)
390            .plaintext())
391    }
392}
393
394/// Build the `Authorization: Bearer <bearer>` header value shared by every
395/// dialer constructor that authenticates with an edge or admin bearer
396/// (`AgentDialer::with_credentials`, each non-Agent dialer's `with_bearer` and
397/// admin-gated `new_admin` — `PersonaDialer::new_admin`,
398/// `CredentialDialer::new_admin` — via [`bearer_header`], and
399/// `RoutineDialer::fire_routine`'s per-call admin bearer) — the ONE place a
400/// bearer string turns into an HTTP header value.
401///
402/// Builds the `HeaderValue` explicitly and fails closed rather than using
403/// connectrpc's `with_default_header`, which silently drops an invalid value
404/// instead of erroring — a dropped header would send the turn unauthenticated
405/// with no signal.
406///
407/// Marks the value [`set_sensitive`](http::HeaderValue::set_sensitive) so the
408/// HTTP stack treats it as a secret: it's excluded from hyper/h2 debug
409/// logging and, on HTTP/2, sent as an HPACK literal rather than added to the
410/// dynamic table (where a compromised intermediary or a debug dump of the
411/// table could otherwise recover it, and where CRIME/BREACH-style compression
412/// side channels could otherwise probe it).
413///
414/// # Errors
415/// Returns [`DialError::InvalidBearer`] if `bearer` can't be encoded as an
416/// HTTP header value (e.g. contains a newline or non-ASCII byte).
417fn bearer_header_value(bearer: &str) -> Result<http::HeaderValue, DialError> {
418    let mut bearer_value = http::HeaderValue::from_str(&format!("Bearer {bearer}"))
419        .map_err(|source| DialError::InvalidBearer(source.to_string()))?;
420    bearer_value.set_sensitive(true);
421    Ok(bearer_value)
422}
423
424/// Build a header map carrying the sensitive `Authorization: Bearer <bearer>`
425/// value from [`bearer_header_value`], shared by every dialer constructor
426/// that authenticates with an edge bearer (`AgentDialer::with_credentials`
427/// and each non-Agent dialer's `with_bearer`).
428///
429/// This header map is folded into the generated Connect client's default
430/// headers (see [`build_control_client`]) and held for the dialer's process
431/// lifetime — a deliberate choice: the generated Connect client only accepts
432/// a default `HeaderMap` at construction, not a per-request override, so
433/// holding the sensitive, already-marked value for the dialer's lifetime is
434/// the accepted shape rather than an oversight. Per-request construction
435/// would require changing the generated-client plumbing, which is out of
436/// scope here.
437///
438/// # Errors
439/// Returns [`DialError::InvalidBearer`] if `bearer` can't be encoded as an
440/// HTTP header value (e.g. contains a newline or non-ASCII byte).
441fn bearer_header(bearer: &str) -> Result<http::HeaderMap, DialError> {
442    let mut headers = http::HeaderMap::new();
443    headers.insert(http::header::AUTHORIZATION, bearer_header_value(bearer)?);
444    Ok(headers)
445}
446
447/// Build a control-plane dialer's inner client: parse `addr`, build the HTTP
448/// transport, apply the shared [`CONTROL_DIAL_TIMEOUT`], and — when `bearer`
449/// is `Some` — attach the `Authorization` header via [`bearer_header`].
450/// `build_client` produces the generated Connect client from the resulting
451/// `(HttpClient, ClientConfig)` pair (e.g. `PersonaServiceClient::new`).
452///
453/// Factors out the `new`/`with_bearer` body every non-`AgentDialer` dialer
454/// (`ApprovalDialer`, `PersonaDialer`, `NotificationDialer`,
455/// `LiveEnrollmentsDialer`, `OperatorMailboxDialer`) previously duplicated
456/// byte-for-byte apart from its client type — mechanical dedup, no behavior
457/// change. [`AgentDialer`] is NOT built through this helper: its
458/// authenticated path additionally signs a per-turn envelope and uses a
459/// different timeout ([`AGENT_DIAL_TIMEOUT`]), so it stays hand-written.
460///
461/// # Errors
462///
463/// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI,
464/// [`DialError::Tls`] if an `https` endpoint's TLS setup fails, or
465/// [`DialError::InvalidBearer`] if `bearer` (when `Some`) can't be encoded as
466/// an HTTP header value.
467fn build_control_client<C>(
468    addr: &str,
469    bearer: Option<&str>,
470    build_client: impl FnOnce(HttpClient, ClientConfig) -> C,
471) -> Result<Arc<C>, DialError> {
472    let uri = addr
473        .parse::<http::Uri>()
474        .map_err(|source| DialError::InvalidAddress {
475            addr: addr.to_owned(),
476            source,
477        })?;
478    let http = http_client_for(&uri)?;
479    let mut config = ClientConfig::new(uri).with_default_timeout(CONTROL_DIAL_TIMEOUT);
480    if let Some(bearer) = bearer {
481        config = config.with_default_headers(bearer_header(bearer)?);
482    }
483    Ok(Arc::new(build_client(http, config)))
484}
485
486/// Per-call options carrying the active span's W3C `traceparent`, so the
487/// control-plane handler re-parents on this turn's span instead of starting a
488/// fresh trace. Cheap when no propagator is installed (the global getter is a
489/// no-op and no header is set). The per-call deadline comes from the client's
490/// `with_default_timeout`, so it need not be repeated here.
491fn traced_options() -> connectrpc::client::CallOptions {
492    let mut headers = http::HeaderMap::new();
493    polyc_runtime::propagation::inject_current_span_into(&mut headers);
494    connectrpc::client::CallOptions::default()
495        .with_headers(headers.into_iter().filter_map(|(n, v)| n.map(|n| (n, v))))
496}
497
498/// Why a turn's prompt was auto-compacted before it ran. The buffered analog
499/// of [`WireCompactionReason`], lifted to a closed Rust enum so surfaces match
500/// on it without touching the wire crate.
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum CompactionReason {
503    /// Older turns were folded into an anchored-iterative summary
504    /// (message-count trigger). The summary survives as the prompt's preamble.
505    Summarized,
506    /// Older tool-output payloads were trimmed to fit the token budget
507    /// (char/token-budget trigger). No messages were dropped, only their bulk.
508    ///
509    /// NOTE: the control plane no longer EMITS this reason — the retroactive
510    /// tool-output truncation layer was removed in favor of token-budget
511    /// summarization (the per-call tool-output cap now bounds individual tool
512    /// results). This variant is retained as a never-emitted-on-happy-path
513    /// decode path so any old persisted/wire value still maps (the enum stays
514    /// exhaustive) and an unknown future wire reason has a quiet fallback.
515    Truncated,
516}
517
518impl CompactionReason {
519    /// Canonical one-line headline (with a leading glyph) for a pre-turn
520    /// compaction notice, shared by every edge so the user-facing wording can't
521    /// drift between Slack, Telegram, and the rest. `summarized_messages` is used
522    /// only by [`Self::Summarized`]. Returns PLAIN text — no markup — so each
523    /// surface applies its own emphasis/escaping (Slack `_…_` mrkdwn, Telegram
524    /// `*…*`, …) without double-formatting.
525    #[must_use]
526    pub fn notice_headline(self, summarized_messages: u32) -> String {
527        match self {
528            Self::Summarized => {
529                let plural = if summarized_messages == 1 { "" } else { "s" };
530                format!(
531                    "🧠 Summarized {summarized_messages} earlier message{plural} to keep the \
532                     conversation manageable"
533                )
534            }
535            Self::Truncated => {
536                "✂️ Trimmed earlier tool output to keep the conversation manageable".to_owned()
537            }
538        }
539    }
540}
541
542/// Incremental event emitted while a turn streams from the control plane.
543///
544/// Unlike [`AgentDialer::run_turn`], which folds the whole turn into one
545/// string, the streaming API surfaces each meaningful step as it arrives so a
546/// live surface (e.g. Slack `chat.appendStream`) can update in place.
547#[derive(Debug, Clone, PartialEq, Eq)]
548pub enum TurnEvent {
549    /// The turn's context was auto-compacted before it ran. Emitted ONCE, as
550    /// the first event of the turn (before any [`TurnEvent::TextDelta`]), so a
551    /// live surface can render a brief notice rather than letting earlier work
552    /// silently vanish from the model's view. The full transcript is retained
553    /// server-side; only this turn's prompt was compacted.
554    ContextCompacted {
555        /// Whether the context was summarized or truncated.
556        reason: CompactionReason,
557        /// Earlier messages folded into the summary (0 when truncated).
558        summarized_messages: u32,
559        /// Short preview of the surviving summary; empty when truncated.
560        summary_preview: String,
561    },
562    /// Incremental assistant answer text (model/assistant role).
563    TextDelta(String),
564    /// A tool call has started, named for a user-visible "thinking step".
565    ToolStarted {
566        /// The tool/function name, or the call id when the name is absent.
567        name: String,
568    },
569    /// The turn paused before executing a tool that requires human approval.
570    /// Emitted (one per pending call) from the terminal `AgentEnd` just before
571    /// [`TurnEvent::Done`]. The caller submits a decision via
572    /// `ApprovalService.Respond` (the THIN path) and re-drives the turn.
573    ApprovalPending {
574        /// Turn that emitted this occurrence of `request_id`.
575        turn_id: String,
576        /// Tool-call id == the approval `request_id` to answer.
577        request_id: String,
578        /// The tool/function name awaiting approval. Raw machine identifier;
579        /// the field of record for trust/audit.
580        tool_name: String,
581        /// Human display label (MCP-style `title`) for the tool, for rendering
582        /// in the approval prompt. May be empty; the surface then derives one
583        /// from `tool_name`.
584        title: String,
585        /// Arguments JSON for the call.
586        args_json: String,
587        /// Why this call is gated, when the pause is an OVERRIDE of a call that
588        /// would not otherwise need approval. Empty for an ordinary gated call;
589        /// non-empty only for the lethal-trifecta / Rule-of-Two containment
590        /// override — rendered on the approval card so the approver sees that
591        /// untrusted content is in context and this is an outbound call.
592        reason: String,
593        /// Short-lived signed capability (`#787`), freshly minted for THIS
594        /// card and scoped to `turn_id` + `request_id` + the conversation it
595        /// belongs to.
596        /// Opaque to the caller: carry it back unmodified to
597        /// [`ApprovalDialer::respond`]. `ApprovalService.Respond` rejects a
598        /// decision whose token is missing, expired, or bound to a different
599        /// request or conversation.
600        resolve_token: String,
601        /// Computed-preview enrichment (`#1496`), set only for a
602        /// `routine_create` call whose arguments parsed against the compiled
603        /// spec shape — `None` for every other tool. Built by the control
604        /// plane from the compiled spec, never from the model's narration.
605        /// Render it with [`approval_preview_text`], never a hand-written
606        /// per-surface rendering.
607        preview: Option<ApprovalPreview>,
608    },
609    /// A question the model asked via `ask_question` is awaiting an answer
610    /// (`#1660`). Emitted (one per pending question) from the terminal
611    /// `AgentEnd` just before [`TurnEvent::Done`] — the question-pause
612    /// SIBLING of [`TurnEvent::ApprovalPending`] above, not a reuse of it:
613    /// `ask_question` is a decision the user makes, not a danger/permission
614    /// gate, so it has its own event-log pair and its own signed-answer
615    /// canonical (`polyc_crypto::question`). The caller submits a decision
616    /// via `QuestionService.Respond` (the THIN path, mirroring
617    /// `ApprovalService.Respond`) and re-drives the turn.
618    QuestionPending {
619        /// Turn that emitted this question occurrence. A provider re-mints a
620        /// tool-call id across turns, so the turn is what separates two
621        /// questions that share a `(call_id, index)` (`#2523`).
622        turn_id: String,
623        /// The `ask_question` call id this question came from. Several
624        /// pending questions can share the same `call_id` (one call can ask
625        /// up to three questions at once) — identity is
626        /// `(turn_id, call_id, index)`.
627        call_id: String,
628        /// This question's position within its call's `questions` array
629        /// (0-based).
630        index: u32,
631        /// The short label (fits a chat-surface button-row heading).
632        header: String,
633        /// The one-sentence question to ask.
634        question: String,
635        /// 2-4 mutually exclusive options to offer.
636        options: Vec<QuestionOptionPrompt>,
637        /// The raw `ask_question` call's full arguments JSON (every question
638        /// in the call, not just this one) — the audit binding a signed
639        /// answer must match against.
640        args_json: String,
641        /// Short-lived signed capability, freshly minted for THIS card and
642        /// scoped to `turn_id` + `call_id` + `index` + the conversation it
643        /// belongs to. Opaque to the caller: carry it back unmodified to
644        /// [`QuestionDialer::respond`]. `QuestionService.Respond` rejects a
645        /// decision whose token is missing, expired, or bound to a
646        /// different occurrence or conversation.
647        answer_token: String,
648        /// Whether this still-unanswered question was already surfaced once
649        /// (a later turn's boundary marker sits after its
650        /// `question_request` in the event log while it stayed unanswered).
651        /// `false`: render the full interactive card; `true`: render a
652        /// compact reminder instead (`#1970`). Derived by the control plane
653        /// at replay time — never edge-cached. Unlike
654        /// [`TurnEvent::ApprovalPending`] (which never carries this bit —
655        /// approvals only ride it through `PendingApproval`'s recovery
656        /// path), THIS streamed event is itself the vehicle for
657        /// re-surfacing a question on every redrive, so it copies the
658        /// control plane's already-computed value straight through rather
659        /// than always reporting `false`.
660        already_surfaced: bool,
661    },
662    /// The turn suspended to delegate to a sub-agent: the model invoked the
663    /// reserved `__handoff_to` primitive. Surfaced (once) from the terminal
664    /// `AgentEnd.handoff` just before [`TurnEvent::Done`]. The child runs in an
665    /// independent conversation. The parent receives no child result. An edge
666    /// can render "delegating…" instead of going silent.
667    HandoffStarted {
668        /// The child agent / planner chosen; empty selects the parent's
669        /// default planner.
670        child_agent_id: String,
671        /// Free-form reason captured for operator visibility.
672        reason: String,
673    },
674    /// The control plane minted an admin invite this turn for the edge to
675    /// deliver privately (agent-evaluable admin invite, `#698`). Surfaced from
676    /// the terminal [`AgentEnd`] just before [`TurnEvent::Done`]. The edge opens
677    /// the target's direct message and delivers the `code` there — and ONLY
678    /// there. The code reached neither the agent nor a channel; the edge fails
679    /// closed (telling the admin, delivering nothing) if it can't reach the
680    /// target privately.
681    InviteDelivery {
682        /// The target's provider-native user id (from the mention markup). The
683        /// edge opens THIS person's direct message.
684        target_user_id: String,
685        /// The single-use invite code — the one secret on this event. Deliver it
686        /// only to the target's direct message; never log it or post it to a
687        /// channel.
688        code: String,
689        /// The inviting admin's display name, for the target-facing copy. May be
690        /// empty; the edge then uses a neutral phrasing.
691        inviter_display: String,
692    },
693    /// A paid tool call this turn needed a USABLE linked wallet (`#519`;
694    /// renewal branch `#2122`). Surfaced from the terminal [`AgentEnd`] just
695    /// before [`TurnEvent::Done`]. Carries only the URL and the renewal bit —
696    /// no copy — so every edge renders the identical "Link a
697    /// wallet"/"Renew access" card text through the shared `polyc_proto`
698    /// helpers instead of hand-writing its own wording.
699    WalletLinkPrompt {
700        /// The deployment's wallet-link URL, when known. `None` = point the
701        /// reader at an admin instead of rendering a dangling button.
702        link_url: Option<String>,
703        /// True when the caller HAD a linked wallet whose delegation is no
704        /// longer usable (expired, revoked, or otherwise broken) — false
705        /// when they never linked one at all. Selects the "renew" card copy
706        /// over the first-time "set one up" copy.
707        renewal: bool,
708        /// True when the caller directly asked to link (or replace) a
709        /// wallet — the `wallet_link` tool called on its own, never the
710        /// `paid_fetch`/`web_fetch` payment-interrupt path `renewal` covers.
711        /// Selects the "here's a secure link" card copy over both the
712        /// first-time and renewal payment-interrupt wording.
713        requested: bool,
714    },
715    /// `wallet_update_limit` minted a fresh TIP-1011 in-place
716    /// spending-limit-update ceremony this turn (issue #1041/#1159).
717    /// Surfaced from the terminal [`AgentEnd`] just before
718    /// [`TurnEvent::Done`]. Carries only the URL and the requested cap — no
719    /// copy — so every edge renders the identical "Update spending cap"
720    /// card text through the shared `polyc_proto` helpers instead of
721    /// hand-writing its own wording.
722    WalletUpdatePrompt {
723        /// The one-time spending-limit-update ceremony URL. Always
724        /// non-empty when this variant is emitted — the tool that produces
725        /// this either mints a URL or refuses outright.
726        update_url: String,
727        /// The requested new daily spending cap, in the settlement
728        /// currency's human units (e.g. `"20"`), echoed on the card.
729        new_limit: String,
730    },
731    /// This turn's `unlink_self` (wallet target) call minted a TIP-1011
732    /// hard-revoke ceremony for the just-unlinked delegated key (issue
733    /// #1042/#1156). Surfaced from the terminal [`AgentEnd`] just before
734    /// [`TurnEvent::Done`]. Carries only the URL — no copy — so every edge
735    /// renders the identical "Revoke access" card text through the shared
736    /// `polyc_proto` helpers instead of hand-writing its own wording.
737    WalletRevokePrompt {
738        /// The one-time hard-revoke ceremony URL. Always non-empty when
739        /// this variant is emitted — only set for a delegated key that was
740        /// provisioned onchain.
741        revoke_url: String,
742    },
743    /// The turn failed durably instead of completing (`#756`). Surfaced from
744    /// the terminal [`AgentEnd.failure`](AgentEnd) just before
745    /// [`TurnEvent::Done`] — a structured, durable fact the control plane
746    /// persisted, not merely the Connect RPC status a dial error would carry.
747    /// `AgentEnd.failure` exists specifically so "external surfaces (Slack,
748    /// the cockpit) can react" (see its wire doc comment); before this
749    /// variant existed, no edge ever read it, and a durably-failed turn with
750    /// no other content (no messages, no pending approvals, no handoff, no
751    /// invites) surfaced as silence — every edge saw `Done` with nothing to
752    /// show and posted nothing.
753    TurnFailed {
754        /// Provider-agnostic failure classification (mirrors the wire
755        /// `TurnFailureKind` 1:1; `TURN_FAILURE_KIND_UNSPECIFIED` maps to
756        /// [`TurnFailureKind::Other`] — a definite failure with an unknown
757        /// reason is still a definite failure).
758        kind: TurnFailureKind,
759        /// Human-readable diagnostic text (the underlying provider/tool
760        /// error). Log-only — an edge's user-facing wording comes from its
761        /// own shared failure-notice helper keyed on
762        /// [`TurnFailureKind::is_retryable`], not this string.
763        message: String,
764    },
765    /// Terminal event: the turn has ended and no further events follow.
766    Done,
767}
768
769/// Provider-agnostic classification of a durable turn failure (`#756`).
770///
771/// Local mirror of the wire `TurnFailureKind`, following the same
772/// decouple-edges-from-the-wire-schema convention as [`CompactionReason`].
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774pub enum TurnFailureKind {
775    /// The provider rejected the call for exceeding its rate limit.
776    RateLimit,
777    /// The call exceeded its deadline.
778    Timeout,
779    /// The provider or a dependency it needs was unreachable.
780    Unavailable,
781    /// A credential/authentication failure.
782    Auth,
783    /// The request itself was invalid.
784    BadRequest,
785    /// Any other failure, including an unspecified wire kind.
786    Other,
787}
788
789impl TurnFailureKind {
790    /// Whether retrying the call could plausibly succeed, mirroring
791    /// [`DialError::is_retryable`]'s transient-vs-terminal split: a provider
792    /// hiccup (rate limit, timeout, unavailable) is worth retrying; a
793    /// credential or malformed-request failure will fail identically again,
794    /// and an unclassified `Other` failure is treated conservatively as
795    /// non-retryable rather than implying a resend will help when it's
796    /// unknown whether it would.
797    #[must_use]
798    pub const fn is_retryable(self) -> bool {
799        matches!(self, Self::RateLimit | Self::Timeout | Self::Unavailable)
800    }
801}
802
803/// Complete input for one durable ingress receive.
804///
805/// Construction requires a stable [`IngressIdentity`]. Optional turn details
806/// are set through the consuming builder methods; there is no default that can
807/// omit source identity.
808#[derive(Debug, Clone)]
809pub struct TurnIngress {
810    conversation_id: String,
811    exec_id: String,
812    source_identity: IngressIdentity,
813    namespace: ClaimedNamespace,
814    messages: Vec<Message>,
815    payment_receipt: Option<PaymentReceipt>,
816    attribution: Attribution,
817    ephemeral_history: bool,
818    ingress_directive: IngressDirective,
819    occurrence: String,
820}
821
822impl TurnIngress {
823    /// Builds the required core of one source event.
824    ///
825    /// `namespace` is the tenancy namespace this conversation belongs to
826    /// (#1691). It is positional rather than a builder option on purpose: a
827    /// surface that mints conversations and names no namespace must fail to
828    /// compile, because an unnamed namespace is how this field became
829    /// decorative in the first place. Take it from
830    /// [`EdgeAdapter::namespace`] so the two
831    /// cannot drift.
832    #[must_use]
833    pub fn new(
834        conversation_id: impl Into<String>,
835        exec_id: impl Into<String>,
836        source_identity: IngressIdentity,
837        namespace: ClaimedNamespace,
838        messages: Vec<Message>,
839    ) -> Self {
840        Self {
841            conversation_id: conversation_id.into(),
842            exec_id: exec_id.into(),
843            source_identity,
844            namespace,
845            messages,
846            payment_receipt: None,
847            attribution: Attribution::default(),
848            ephemeral_history: false,
849            ingress_directive: IngressDirective::default(),
850            occurrence: String::new(),
851        }
852    }
853
854    /// Adds a settled inbound payment receipt.
855    #[must_use]
856    pub fn with_payment_receipt(mut self, receipt: PaymentReceipt) -> Self {
857        self.payment_receipt = Some(receipt);
858        self
859    }
860
861    /// Adds caller and participant attribution asserted by the edge.
862    #[must_use]
863    pub fn with_attribution(mut self, attribution: Attribution) -> Self {
864        self.attribution = attribution;
865        self
866    }
867
868    /// Adds the edge-authored ingress policy.
869    #[must_use]
870    pub fn with_ingress_directive(mut self, directive: IngressDirective) -> Self {
871        self.ingress_directive = directive;
872        self
873    }
874
875    /// Declares that the turn starts without replaying prior transcript.
876    #[must_use]
877    pub const fn with_ephemeral_history(mut self) -> Self {
878        self.ephemeral_history = true;
879        self
880    }
881
882    /// Adds the stable scheduled occurrence that caused this ingress.
883    #[must_use]
884    pub fn with_occurrence(mut self, occurrence: impl Into<String>) -> Self {
885        self.occurrence = occurrence.into();
886        self
887    }
888}
889
890/// Proof that State durably received one [`TurnIngress`].
891///
892/// Only [`AgentDialer::receive_ingress`] can construct this type. Opening a
893/// transport stream or building a request cannot produce it.
894#[derive(Debug, Clone)]
895pub struct DurablyReceivedTurn {
896    receipt: WireIngressReceipt,
897    attach_attempt: String,
898}
899
900impl DurablyReceivedTurn {
901    /// Returns State's stable dispatch handle for this source event.
902    #[must_use]
903    pub fn dispatch_id(&self) -> &str {
904        &self.receipt.dispatch_id
905    }
906
907    /// Returns the conversation bound to the durable receipt.
908    #[must_use]
909    pub fn conversation_id(&self) -> &str {
910        &self.receipt.conversation_id
911    }
912
913    /// Returns the opaque durable receipt bytes.
914    #[must_use]
915    pub fn receipt_bytes(&self) -> &[u8] {
916        &self.receipt.receipt
917    }
918}
919
920/// Reusable handle for dialing the polychrome control plane.
921#[derive(Clone)]
922pub struct AgentDialer {
923    client: Arc<AgentServiceClient<HttpClient>>,
924    /// Set only by [`Self::with_credentials`]. Turn ingress fails locally when
925    /// this is `None`, because stable source identity must ride inside a signed
926    /// assertion. Credential-free non-ingress calls remain available.
927    credentials: Option<Arc<EdgeCredentials>>,
928}
929
930impl AgentDialer {
931    /// Build a dialer pointed at `addr` (expects `http://host:port`).
932    ///
933    /// Unauthenticated: no bearer header rides its calls. This constructor is
934    /// suitable only for non-ingress methods such as health/classification;
935    /// [`Self::receive_ingress`] refuses it before network I/O.
936    ///
937    /// # Errors
938    ///
939    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI.
940    pub fn new(addr: &str) -> Result<Self, DialError> {
941        let uri = addr
942            .parse::<http::Uri>()
943            .map_err(|source| DialError::InvalidAddress {
944                addr: addr.to_owned(),
945                source,
946            })?;
947        let http = http_client_for(&uri)?;
948        let config = ClientConfig::new(uri).with_default_timeout(AGENT_DIAL_TIMEOUT);
949        let client = AgentServiceClient::new(http, config);
950        Ok(Self {
951            client: Arc::new(client),
952            credentials: None,
953        })
954    }
955
956    /// Build a dialer pointed at `addr`, authenticated with `creds`.
957    ///
958    /// Every call this dialer makes carries an `Authorization: Bearer
959    /// <creds.bearer()>` header, and every turn it dials signs a fresh
960    /// [`AssertedAttribution`] envelope (`creds.edge_id()`, this turn's
961    /// `conversation_id`, a per-call nonce/timestamp, and the caller
962    /// [`Attribution`] passed to the turn) onto `AgentStart.asserted_attribution`.
963    ///
964    /// # Errors
965    ///
966    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
967    /// [`DialError::InvalidBearer`] if `creds.bearer()` can't be encoded as
968    /// an HTTP header value.
969    pub fn with_credentials(addr: &str, creds: EdgeCredentials) -> Result<Self, DialError> {
970        let uri = addr
971            .parse::<http::Uri>()
972            .map_err(|source| DialError::InvalidAddress {
973                addr: addr.to_owned(),
974                source,
975            })?;
976        let http = http_client_for(&uri)?;
977        let headers = bearer_header(creds.bearer())?;
978        let config = ClientConfig::new(uri)
979            .with_default_timeout(AGENT_DIAL_TIMEOUT)
980            .with_default_headers(headers);
981        let client = AgentServiceClient::new(http, config);
982        Ok(Self {
983            client: Arc::new(client),
984            credentials: Some(Arc::new(creds)),
985        })
986    }
987
988    /// Build an [`ApprovalDialer`] for the SAME control-plane endpoint.
989    /// `AgentService` and `ApprovalService` are served on one Connect port, so
990    /// an approval client reuses the agent address — callers that already hold
991    /// an `AgentDialer` don't need to thread a second address.
992    ///
993    /// # Errors
994    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI.
995    pub fn approval_dialer(addr: &str) -> Result<ApprovalDialer, DialError> {
996        ApprovalDialer::new(addr)
997    }
998
999    /// Build an [`ApprovalDialer`] for the SAME control-plane endpoint,
1000    /// sharing this dialer's edge credentials — the bearer on every call, and
1001    /// the identity key that signs an approval's `AssertedApproval`
1002    /// (`#1553`). The authenticated sibling of [`Self::approval_dialer`], for
1003    /// an edge that has already built its `AgentDialer`.
1004    ///
1005    /// On the unauthenticated [`Self::new`] path there are no credentials to
1006    /// share, so this returns exactly what [`Self::approval_dialer`] does:
1007    /// no bearer, and no responder asserted on any decision.
1008    ///
1009    /// # Errors
1010    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
1011    /// [`DialError::InvalidBearer`] if the credentials' bearer can't be
1012    /// encoded as an HTTP header value.
1013    pub fn approval_dialer_with_credentials(
1014        &self,
1015        addr: &str,
1016    ) -> Result<ApprovalDialer, DialError> {
1017        self.credentials.as_ref().map_or_else(
1018            || ApprovalDialer::new(addr),
1019            |creds| ApprovalDialer::with_credentials(addr, Arc::clone(creds)),
1020        )
1021    }
1022
1023    /// Build a [`QuestionDialer`] for the SAME control-plane endpoint,
1024    /// carrying this dialer's edge bearer. The question sibling of
1025    /// [`Self::approval_dialer_with_credentials`].
1026    ///
1027    /// Shares the bearer and NOT the signing key. An approval decision is
1028    /// signed; a question answer is not, because answering authorizes
1029    /// nothing (see [`QuestionDialer::respond`]).
1030    ///
1031    /// The bearer is still required. `QuestionService` sits behind the
1032    /// control plane's `require_edge_bearer` layer like every other RPC on
1033    /// that listener, so an edge that builds an unauthenticated dialer sees
1034    /// every answer fail closed with 401 — the `#1660` incident, described on
1035    /// [`QuestionDialer::new`].
1036    ///
1037    /// On the unauthenticated [`Self::new`] path there is no bearer to share,
1038    /// so this returns exactly what [`QuestionDialer::new`] does.
1039    ///
1040    /// # Errors
1041    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
1042    /// [`DialError::InvalidBearer`] if the credentials' bearer can't be
1043    /// encoded as an HTTP header value.
1044    pub fn question_dialer_with_credentials(
1045        &self,
1046        addr: &str,
1047    ) -> Result<QuestionDialer, DialError> {
1048        self.credentials.as_ref().map_or_else(
1049            || QuestionDialer::new(addr),
1050            |creds| QuestionDialer::with_bearer(addr, creds.bearer()),
1051        )
1052    }
1053
1054    /// Durably receives one source event without waiting for its turn output.
1055    ///
1056    /// The returned [`DurablyReceivedTurn`] is acknowledgement authority: it
1057    /// exists only after the unary `ReceiveIngress` response carries a
1058    /// nonempty State receipt and a matching signed source identity. Response
1059    /// consumption starts separately through [`Self::attach_ingress`].
1060    ///
1061    /// # Errors
1062    ///
1063    /// Returns [`DialError::MissingIngressCredentials`] before network I/O for
1064    /// an unauthenticated dialer, [`DialError::Connect`] for transport errors,
1065    /// or [`DialError::InvalidIngressReceipt`] for an incomplete or mismatched
1066    /// success response.
1067    pub async fn receive_ingress(
1068        &self,
1069        ingress: TurnIngress,
1070    ) -> Result<DurablyReceivedTurn, DialError> {
1071        let expected_source = ingress.source_identity.to_wire();
1072        let expected_conversation = ingress.conversation_id.clone();
1073        let request = build_request(ingress, self.credentials.as_deref())?;
1074        let receipt = self
1075            .client
1076            .receive_ingress_with_options(request, traced_options())
1077            .await?
1078            .into_owned();
1079        validate_ingress_receipt(&receipt, &expected_source, &expected_conversation)?;
1080        Ok(DurablyReceivedTurn {
1081            receipt,
1082            // One proof object is one execution contender. Clones and exact
1083            // attachment retries preserve it; a crash/source redelivery that
1084            // receives a new proof mints a distinct contender identity.
1085            attach_attempt: uuid::Uuid::now_v7().to_string(),
1086        })
1087    }
1088
1089    /// Attaches to a durably received turn and projects its response stream.
1090    ///
1091    /// The proof-carrying [`DurablyReceivedTurn`] is required; a dispatch id
1092    /// string or a successfully opened transport cannot call this method.
1093    /// Reattaching with the same proof never starts the turn twice. Until a
1094    /// durable output outbox exists, Control fails that duplicate attachment
1095    /// closed rather than pretending it can replay response bytes it did not
1096    /// persist; callers must treat a lost response stream as outcome-unknown.
1097    ///
1098    /// # Errors
1099    ///
1100    /// The outer result returns [`DialError::Connect`] if attachment fails.
1101    /// Each stream item carries response transport/decode failures inline.
1102    pub async fn attach_ingress(
1103        &self,
1104        received: &DurablyReceivedTurn,
1105    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>> + use<>, DialError> {
1106        self.run_turn_streaming_received(received).await
1107    }
1108
1109    /// Attaches to a durably received turn and collects its buffered result.
1110    ///
1111    /// This is the response half for request/response edges: they await
1112    /// [`Self::receive_ingress`], acknowledge the source using that proof, and
1113    /// may then collect text and pending interactions without keeping source
1114    /// acknowledgement coupled to turn execution.
1115    ///
1116    /// # Errors
1117    ///
1118    /// Returns [`DialError::Connect`] for attachment, stream, or decode
1119    /// failures.
1120    pub async fn attach_ingress_buffered(
1121        &self,
1122        received: &DurablyReceivedTurn,
1123    ) -> Result<BufferedTurn, DialError> {
1124        self.run_turn_buffered_received(received).await
1125    }
1126
1127    /// Run one turn against the control plane and collect the aggregated
1128    /// text response.
1129    ///
1130    /// `conversation_id` is the stable id for the conversation (the Slack
1131    /// adapter derives it from the thread; the CLI takes it as an argument).
1132    /// `user_text` is the user message with any bot mention already stripped.
1133    /// Returns the concatenated assistant text across every batch in the
1134    /// response stream, or `Ok(String::new())` if the turn produced no text.
1135    /// Non-text content variants (tool calls, tool results, thoughts) are
1136    /// rendered as bracketed placeholders.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns [`DialError::Connect`] for any transport/stream/encoding
1141    /// error from the `AgentService` call.
1142    pub async fn run_turn(
1143        &self,
1144        conversation_id: &str,
1145        exec_id: &str,
1146        source_identity: IngressIdentity,
1147        namespace: ClaimedNamespace,
1148        user_text: &str,
1149    ) -> Result<String, DialError> {
1150        self.run_turn_with(
1151            conversation_id,
1152            exec_id,
1153            source_identity,
1154            namespace,
1155            user_text,
1156            Attribution::default(),
1157        )
1158        .await
1159    }
1160
1161    /// Like [`run_turn`](Self::run_turn) but attributes the turn to a caller
1162    /// (and participants). Non-streaming edges that resolve an identity via
1163    /// [`EdgeAdapter::caller`] use this so their turns populate
1164    /// `AgentStart.caller` (persona attribution) — the buffered analog of
1165    /// [`run_turn_streaming_messages_with`](Self::run_turn_streaming_messages_with).
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns [`DialError::Connect`] for any transport/stream/encoding error.
1170    pub async fn run_turn_with(
1171        &self,
1172        conversation_id: &str,
1173        exec_id: &str,
1174        source_identity: IngressIdentity,
1175        namespace: ClaimedNamespace,
1176        user_text: &str,
1177        attribution: Attribution,
1178    ) -> Result<String, DialError> {
1179        Ok(self
1180            .run_turn_with_approvals(
1181                conversation_id,
1182                exec_id,
1183                source_identity,
1184                namespace,
1185                user_text,
1186                attribution,
1187                IngressDirective::default(),
1188            )
1189            .await?
1190            .reply)
1191    }
1192
1193    /// Like [`run_turn_with`](Self::run_turn_with) but ALSO surfaces any
1194    /// [`PendingApprovalPrompt`]s the turn paused on, mirroring the streaming
1195    /// path's [`TurnEvent::ApprovalPending`] projection of the same terminal
1196    /// `AgentEnd.pending_approvals` field.
1197    ///
1198    /// Buffered edges (Discord/email/trigger/A2A) that don't drive the
1199    /// streaming API still need to render an approve/deny affordance instead
1200    /// of losing a gated call to the scaffolding-placeholder fallback — this
1201    /// is the buffered-API variant that lets them. Call
1202    /// [`ApprovalDialer::respond`] for a decision, then re-drive with this
1203    /// same method (empty `user_text`) to resume the turn.
1204    ///
1205    /// `ingress_directive` is the edge's own policy for this turn (`#68`) —
1206    /// a step-budget cap, an advisory priority, and/or a required approver.
1207    /// An edge with no such policy passes [`IngressDirective::default`]
1208    /// (empty; byte-for-byte unaffected).
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns [`DialError::Connect`] for any transport/stream/encoding
1213    /// error from the `AgentService` call.
1214    #[allow(
1215        clippy::too_many_arguments,
1216        reason = "each argument is an independent turn premise the signed \
1217                  envelope binds; grouping them would hide which fields the \
1218                  edge must supply, and a missing one must not compile"
1219    )]
1220    pub async fn run_turn_with_approvals(
1221        &self,
1222        conversation_id: &str,
1223        exec_id: &str,
1224        source_identity: IngressIdentity,
1225        namespace: ClaimedNamespace,
1226        user_text: &str,
1227        attribution: Attribution,
1228        ingress_directive: IngressDirective,
1229    ) -> Result<BufferedTurn, DialError> {
1230        let ingress = TurnIngress::new(
1231            conversation_id,
1232            exec_id,
1233            source_identity,
1234            namespace,
1235            vec![text_message("user", user_text)],
1236        )
1237        .with_attribution(attribution)
1238        .with_ingress_directive(ingress_directive);
1239        let received = self.receive_ingress(ingress).await?;
1240        self.attach_ingress_buffered(&received).await
1241    }
1242
1243    /// Drive one **routine fire** turn: like
1244    /// [`run_turn_with_approvals`](Self::run_turn_with_approvals), but the
1245    /// conversation declares ephemeral history (#843), so the control plane
1246    /// starts the model on an empty transcript instead of replaying prior fires.
1247    /// Used by the trigger edge's fan-out — each periodic firing is independent,
1248    /// never re-feeding past digests / tool loops into context. The event log
1249    /// still records every fire for forensics, and a gated call still surfaces
1250    /// its [`PendingApprovalPrompt`]s so the pause is discoverable.
1251    ///
1252    /// The caller builds `ingress` with [`TurnIngress::with_ephemeral_history`],
1253    /// [`TurnIngress::with_occurrence`], and the routine's policy through
1254    /// [`TurnIngress::with_ingress_directive`]. Keeping those fields in one
1255    /// required-source-identity value prevents a routine variant from dropping
1256    /// its idempotency key as its options grow.
1257    ///
1258    /// # Errors
1259    ///
1260    /// Returns [`DialError::Connect`] for any transport/stream/encoding error.
1261    pub async fn run_routine_turn(&self, ingress: TurnIngress) -> Result<BufferedTurn, DialError> {
1262        let received = self.receive_ingress(ingress).await?;
1263        self.attach_ingress_buffered(&received).await
1264    }
1265
1266    /// Attach to one durable receipt and fold the
1267    /// response envelopes into a [`BufferedTurn`] — the single transport body
1268    /// [`run_turn_with`](Self::run_turn_with) and
1269    /// [`run_turn_with_approvals`](Self::run_turn_with_approvals) share, so
1270    /// the two buffered call sites can't drift on what a terminal `AgentEnd`
1271    /// means.
1272    async fn run_turn_buffered_received(
1273        &self,
1274        received: &DurablyReceivedTurn,
1275    ) -> Result<BufferedTurn, DialError> {
1276        let mut stream = self
1277            .client
1278            .attach_ingress_with_options(
1279                AttachIngressRequest {
1280                    receipt: buffa::MessageField::some(received.receipt.clone()),
1281                    attach_attempt: received.attach_attempt.clone(),
1282                    ..Default::default()
1283                },
1284                traced_options(),
1285            )
1286            .await?;
1287        // The assistant's prose answer. Tool calls, tool results, and thoughts
1288        // are intermediate scaffolding — they're aggregated separately and only
1289        // surfaced when the turn produced no text at all (e.g. a turn that ends
1290        // on a tool_call / approval pause), so a normal answer reads cleanly
1291        // instead of "[tool_call:…]\n[tool_result:…]\nIt is 3pm."
1292        let mut text_parts: Vec<String> = Vec::new();
1293        let mut scaffolding: Vec<String> = Vec::new();
1294        // A paid call this turn needed a linked wallet (`#519`): absent by
1295        // default, `Present(url)` once `AgentEnd` carries the signal.
1296        let mut wallet_link_prompt = WalletLinkPrompt::None;
1297        // `wallet_update_limit` minted a fresh spending-limit-update
1298        // ceremony this turn (issue #1041/#1159): `None` by default,
1299        // `Some((update_url, new_limit))` once `AgentEnd` carries the
1300        // signal.
1301        let mut wallet_update_prompt: Option<(String, String)> = None;
1302        // This turn's `unlink_self` (wallet target) call minted a
1303        // hard-revoke ceremony (issue #1042/#1156): `None` by default,
1304        // `Some(revoke_url)` once `AgentEnd` carries the signal.
1305        let mut wallet_revoke_prompt: Option<String> = None;
1306        // Gated calls the turn paused on (`AgentEnd.pending_approvals`) — the
1307        // buffered analog of the streaming path's `TurnEvent::ApprovalPending`
1308        // (`events_from_end`). Empty unless the turn paused.
1309        let mut pending_approvals: Vec<PendingApprovalPrompt> = Vec::new();
1310        // `#1660`: the question-pause SIBLING of `pending_approvals` above —
1311        // `AgentEnd.pending_questions`, the buffered analog of
1312        // `TurnEvent::QuestionPending`.
1313        let mut pending_questions: Vec<PendingQuestionPrompt> = Vec::new();
1314        while let Some(view) = stream.message().await? {
1315            let response = view.to_owned_message();
1316            match response.r#type {
1317                Some(agent_response::Type::Outputs(outputs)) => {
1318                    for msg in outputs.messages {
1319                        aggregate_output_message(msg, &mut text_parts, &mut scaffolding);
1320                    }
1321                }
1322                // `End` carries the turn's answer already folded into
1323                // `text_parts`/`scaffolding` above; `wallet_link_prompt` and
1324                // `pending_approvals` mirror the streaming path's identical
1325                // projection (`events_from_end`) so the buffered and
1326                // streaming APIs cannot drift on either signal. A pre-turn
1327                // compaction notice carries no answer text; an empty
1328                // envelope is defensive (the wire allows it). Deliberately
1329                // NOT a `break` on `End`: returning there would close our
1330                // half of the stream while the transport may still have
1331                // frames (or only its trailer) in flight, which registers as
1332                // a client-cancelled call to anything that inspects gRPC
1333                // status on this path. Keep reading — mirrors the
1334                // drain-to-EOF shape in `harness_dialer::run_turn_streamed`
1335                // — until `stream.message()` itself returns `None` at
1336                // transport EOF.
1337                Some(agent_response::Type::End(end)) => {
1338                    if let Some(prompt) = end.wallet_link_prompt.into_option() {
1339                        let renewal = prompt.renewal;
1340                        let requested = prompt.requested;
1341                        let link_url =
1342                            (!prompt.link_url.trim().is_empty()).then_some(prompt.link_url);
1343                        wallet_link_prompt = WalletLinkPrompt::Present {
1344                            link_url,
1345                            renewal,
1346                            requested,
1347                        };
1348                    }
1349                    if let Some(prompt) = end.wallet_update_prompt.into_option() {
1350                        wallet_update_prompt = Some((prompt.update_url, prompt.new_limit));
1351                    }
1352                    if let Some(prompt) = end.wallet_revoke_prompt.into_option() {
1353                        wallet_revoke_prompt = Some(prompt.revoke_url);
1354                    }
1355                    pending_approvals = end
1356                        .pending_approvals
1357                        .into_iter()
1358                        .map(PendingApprovalPrompt::from)
1359                        .collect();
1360                    pending_questions = end
1361                        .pending_questions
1362                        .into_iter()
1363                        .map(PendingQuestionPrompt::from)
1364                        .collect();
1365                }
1366                Some(agent_response::Type::Compacted(_)) | None => {}
1367            }
1368        }
1369        Ok(BufferedTurn {
1370            reply: finalize_buffered_reply(
1371                &text_parts,
1372                &scaffolding,
1373                wallet_link_prompt,
1374                wallet_update_prompt
1375                    .as_ref()
1376                    .map(|(url, limit)| (url.as_str(), limit.as_str())),
1377                wallet_revoke_prompt.as_deref(),
1378            ),
1379            pending_approvals,
1380            pending_questions,
1381        })
1382    }
1383
1384    /// Run one turn against the control plane and stream each meaningful step
1385    /// as a [`TurnEvent`], for live surfaces that update in place.
1386    ///
1387    /// The request is built identically to [`AgentDialer::run_turn`]; see that
1388    /// method for the meaning of `conversation_id`, `exec_id`, and
1389    /// `user_text`. The returned stream yields:
1390    ///
1391    /// - [`TurnEvent::TextDelta`] for each model/assistant-role text block,
1392    /// - [`TurnEvent::ToolStarted`] when a tool call begins, and
1393    /// - [`TurnEvent::Done`] once at end-of-turn, after which the stream ends.
1394    ///
1395    /// Tool-role result echoes and empty/non-textual blocks produce no event.
1396    ///
1397    /// # Errors
1398    ///
1399    /// The outer `Result` carries a [`DialError::Connect`] if opening the
1400    /// stream fails. Each item is a `Result` so per-message transport/decode
1401    /// errors surface inline without tearing down the whole stream type.
1402    pub async fn run_turn_streaming(
1403        &self,
1404        conversation_id: &str,
1405        exec_id: &str,
1406        source_identity: IngressIdentity,
1407        namespace: ClaimedNamespace,
1408        user_text: &str,
1409    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
1410        self.run_turn_streaming_messages(
1411            conversation_id,
1412            exec_id,
1413            source_identity,
1414            namespace,
1415            vec![text_message("user", user_text)],
1416        )
1417        .await
1418    }
1419
1420    /// Like [`Self::run_turn_streaming`] but takes a pre-built (e.g. attributed
1421    /// multi-party) message list as the turn input.
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns [`DialError::Connect`] for any transport/stream/encoding error.
1426    pub async fn run_turn_streaming_messages(
1427        &self,
1428        conversation_id: &str,
1429        exec_id: &str,
1430        source_identity: IngressIdentity,
1431        namespace: ClaimedNamespace,
1432        messages: Vec<Message>,
1433    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
1434        self.run_turn_streaming_ingress(TurnIngress::new(
1435            conversation_id,
1436            exec_id,
1437            source_identity,
1438            namespace,
1439            messages,
1440        ))
1441        .await
1442    }
1443
1444    /// The full-fidelity variant: one method carries everything a turn's
1445    /// request can — a settled inbound [`PaymentReceipt`] (the control plane
1446    /// persists a signed `payment_receipt` event in the turn's atomic batch),
1447    /// the caller [`Attribution`] (resolved to durable personas and recorded
1448    /// as `caller`/`participant` events), and the edge's own
1449    /// [`IngressDirective`] (`#68`) — a step-budget cap, an advisory
1450    /// priority, and/or a required approver. One method rather than a matrix
1451    /// of variants, so a paid *and* attributed *and* directed edge can't
1452    /// silently drop one of the three. An edge with no ingress policy passes
1453    /// [`IngressDirective::default`].
1454    ///
1455    /// # Errors
1456    ///
1457    /// Returns [`DialError::Connect`] for any transport/stream/encoding error.
1458    #[allow(clippy::too_many_arguments)] // compatibility surface gained required source identity
1459    pub async fn run_turn_streaming_messages_with(
1460        &self,
1461        conversation_id: &str,
1462        exec_id: &str,
1463        source_identity: IngressIdentity,
1464        namespace: ClaimedNamespace,
1465        messages: Vec<Message>,
1466        payment_receipt: Option<PaymentReceipt>,
1467        attribution: Attribution,
1468        ingress_directive: IngressDirective,
1469    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
1470        let mut ingress = TurnIngress::new(
1471            conversation_id,
1472            exec_id,
1473            source_identity,
1474            namespace,
1475            messages,
1476        )
1477        .with_attribution(attribution)
1478        .with_ingress_directive(ingress_directive);
1479        if let Some(receipt) = payment_receipt {
1480            ingress = ingress.with_payment_receipt(receipt);
1481        }
1482        self.run_turn_streaming_ingress(ingress).await
1483    }
1484
1485    /// Runs a fully assembled durable ingress turn.
1486    ///
1487    /// Prefer this variant when optional turn fields are naturally composed
1488    /// through [`TurnIngress`]'s builders.
1489    ///
1490    /// # Errors
1491    ///
1492    /// Returns [`DialError::Connect`] for any transport/stream/encoding error.
1493    pub async fn run_turn_streaming_ingress(
1494        &self,
1495        ingress: TurnIngress,
1496    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
1497        let received = self.receive_ingress(ingress).await?;
1498        self.run_turn_streaming_received(&received).await
1499    }
1500
1501    /// Attach to one durable receipt and project the
1502    /// response envelopes into [`TurnEvent`]s — the single transport body
1503    /// every streaming variant shares.
1504    async fn run_turn_streaming_received(
1505        &self,
1506        received: &DurablyReceivedTurn,
1507    ) -> Result<impl Stream<Item = Result<TurnEvent, DialError>> + use<>, DialError> {
1508        let mut stream = self
1509            .client
1510            .attach_ingress_with_options(
1511                AttachIngressRequest {
1512                    receipt: buffa::MessageField::some(received.receipt.clone()),
1513                    attach_attempt: received.attach_attempt.clone(),
1514                    ..Default::default()
1515                },
1516                traced_options(),
1517            )
1518            .await?;
1519        Ok(async_stream::try_stream! {
1520            // Set once an explicit `AgentEnd` has been seen and its events
1521            // (including the terminal `Done`) have been yielded, so the
1522            // fallback below doesn't yield a second `Done`.
1523            let mut ended = false;
1524            while let Some(view) = stream.message().await? {
1525                let response = view.to_owned_message();
1526                match response.r#type {
1527                    // A pre-turn compaction notice, surfaced before any text so
1528                    // the live surface can flag it ahead of the answer.
1529                    Some(agent_response::Type::Compacted(c)) => {
1530                        yield event_from_compacted(*c);
1531                    }
1532                    Some(agent_response::Type::Outputs(outputs)) => {
1533                        for msg in outputs.messages {
1534                            if let Some(event) = message_to_event(msg) {
1535                                yield event;
1536                            }
1537                        }
1538                    }
1539                    Some(agent_response::Type::End(end)) => {
1540                        // The terminal envelope's extensions (pending approvals,
1541                        // a sub-agent handoff) surface before the terminal Done
1542                        // so a live surface can prompt / show "delegating…".
1543                        for event in events_from_end(*end) {
1544                            yield event;
1545                        }
1546                        ended = true;
1547                        // Deliberately NOT a `return`: returning here would
1548                        // drop our half of the stream while the transport may
1549                        // still have frames (or only its trailer) in flight,
1550                        // which registers as a client-cancelled call to
1551                        // anything that inspects gRPC status on this path.
1552                        // Keep reading — mirrors the drain-to-EOF shape in
1553                        // `harness_dialer::run_turn_streamed` — until
1554                        // `stream.message()` itself returns `None` at
1555                        // transport EOF.
1556                    }
1557                    // Empty envelope — defensive; ignore (mirrors `run_turn`).
1558                    None => {}
1559                }
1560            }
1561            // Stream closed without an explicit AgentEnd: still signal a
1562            // terminal event so the caller can finalise the live surface.
1563            if !ended {
1564                yield TurnEvent::Done;
1565            }
1566        })
1567    }
1568
1569    /// Ask the control plane's participation gate whether to reply to the
1570    /// latest message of a (multi-party) thread. Returns `true` on `respond`
1571    /// and `false` on `ignore`. The gate runs a cheap classifier model
1572    /// server-side and never runs a turn.
1573    ///
1574    /// `surface` names the calling edge's surface (for example `"Slack"`);
1575    /// it is rendered into the classifier prompt so the gate reads the
1576    /// thread in its real setting. Empty keeps the prompt surface-neutral.
1577    ///
1578    /// `source_identity` names the source event this evaluation belongs to. It
1579    /// travels inside the signed envelope, which is what lets the control
1580    /// plane derive a model tenant: the gate spends against one, so it
1581    /// authenticates its caller exactly as a turn does.
1582    ///
1583    /// # Errors
1584    ///
1585    /// Returns [`DialError::MissingIngressCredentials`] when this client holds
1586    /// no edge credential to sign with, and [`DialError::Connect`] for any
1587    /// transport/encoding error.
1588    pub async fn should_respond(
1589        &self,
1590        conversation_id: &str,
1591        bot_name: &str,
1592        surface: &str,
1593        transcript: Vec<ParticipantMessage>,
1594        source_identity: &IngressIdentity,
1595        namespace: &ClaimedNamespace,
1596    ) -> Result<bool, DialError> {
1597        let credentials = self
1598            .credentials
1599            .as_ref()
1600            .ok_or(DialError::MissingIngressCredentials)?;
1601        let asserted_attribution = build_classify_attribution(
1602            credentials,
1603            conversation_id,
1604            source_identity,
1605            namespace,
1606            &transcript,
1607            bot_name,
1608            surface,
1609        );
1610        let request = ClassifyRequest {
1611            conversation_id: conversation_id.to_owned(),
1612            bot_name: bot_name.to_owned(),
1613            surface: surface.to_owned(),
1614            transcript,
1615            asserted_attribution: buffa::MessageField::some(asserted_attribution),
1616            ..Default::default()
1617        };
1618        let resp = self
1619            .client
1620            .classify_with_options(request, traced_options())
1621            .await?
1622            .into_owned();
1623        Ok(resp.verdict.to_i32() == Verdict::VERDICT_RESPOND as i32)
1624    }
1625
1626    /// Cancel the conversation's in-flight turn without dropping a Connect
1627    /// stream. Returns `true` if a running turn was found and signalled to
1628    /// cancel; `false` is the idempotent no-op (no turn running on the replica
1629    /// that served this call).
1630    ///
1631    /// # Errors
1632    /// Returns [`DialError::Connect`] for any transport/encoding error.
1633    pub async fn interrupt(&self, conversation_id: &str) -> Result<bool, DialError> {
1634        let request = InterruptRequest {
1635            conversation_id: conversation_id.to_owned(),
1636            ..Default::default()
1637        };
1638        let resp = self
1639            .client
1640            .interrupt_with_options(request, traced_options())
1641            .await?
1642            .into_owned();
1643        Ok(resp.interrupted)
1644    }
1645}
1646
1647/// A human's decision on a pending approval, as the edges present it.
1648///
1649/// One enum instead of a widening tuple of booleans, so an edge maps its button
1650/// once and its accessors ([`Self::approved`], [`Self::approved_for_session`],
1651/// [`Self::is_abort`]) yield the flags [`ApprovalDialer::respond`] takes — the
1652/// two can't drift.
1653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1654pub enum ApprovalChoice {
1655    /// Run this one call; ask again next time.
1656    Approve,
1657    /// Run it AND remember the approval for the caller's session ("don't ask
1658    /// again") — honored only for idempotent tools.
1659    ApproveForSession,
1660    /// Decline this call; the turn continues (a synthetic denial result).
1661    Deny,
1662    /// Decline this call AND stop the turn — the edge does not re-drive.
1663    Abort,
1664    /// Send the call back without approving or denying it (#67): the pause is
1665    /// recorded as deferred but stays open for a later decision.
1666    Defer,
1667}
1668
1669impl ApprovalChoice {
1670    /// Whether the call was approved (approve or approve-for-session).
1671    #[must_use]
1672    pub const fn approved(self) -> bool {
1673        matches!(self, Self::Approve | Self::ApproveForSession)
1674    }
1675
1676    /// Whether the approval is remembered for the session.
1677    #[must_use]
1678    pub const fn approved_for_session(self) -> bool {
1679        matches!(self, Self::ApproveForSession)
1680    }
1681
1682    /// Whether the turn should stop (no re-drive).
1683    #[must_use]
1684    pub const fn is_abort(self) -> bool {
1685        matches!(self, Self::Abort)
1686    }
1687
1688    /// Whether the call was deferred ("send back", #67) — neither approved nor
1689    /// denied; recorded but left pending.
1690    #[must_use]
1691    pub const fn is_defer(self) -> bool {
1692        matches!(self, Self::Defer)
1693    }
1694}
1695
1696/// The one line of copy shown after a human decides a pending approval.
1697///
1698/// Shared by every edge so the identical state reads identically everywhere
1699/// (Slack replaces the approval card with this; Telegram edits the prompt
1700/// message to this). `label` is the tool's friendly display name, already
1701/// resolved by the caller (title, or a `polyc_proto::humanize_tool_name`
1702/// fallback).
1703#[must_use]
1704pub fn approval_decided_text(label: &str, choice: ApprovalChoice, decider: &str) -> String {
1705    match choice {
1706        ApprovalChoice::ApproveForSession => format!(
1707            "✅ Approved by {decider} — running \"{label}\"… (won't ask again this session)"
1708        ),
1709        ApprovalChoice::Approve => format!("✅ Approved by {decider} — running \"{label}\"…"),
1710        ApprovalChoice::Deny => format!("🚫 Denied by {decider} — \"{label}\" was not run."),
1711        ApprovalChoice::Abort => {
1712            format!("🛑 Aborted by {decider} — \"{label}\" was not run; the turn was stopped.")
1713        }
1714        ApprovalChoice::Defer => {
1715            format!("↩️ Sent back by {decider} — \"{label}\" is still waiting for a decision.")
1716        }
1717    }
1718}
1719
1720/// The one line of copy shown once an approved call's re-drive has actually
1721/// executed (`#743`).
1722///
1723/// The sibling of [`approval_decided_text`] one step later in the lifecycle:
1724/// that function's "running…" line covers the gap between the decision and
1725/// execution; this replaces it once the runtime knows the outcome, so the
1726/// card never sits on a stale "running…" after the tool has already
1727/// finished. `label` is the tool's friendly display name (title, or a
1728/// `humanize_tool_name` fallback), `decider` is who approved it, and
1729/// `success` distinguishes a clean run from one that errored — the runtime,
1730/// not the model, owns this line: it is built from the actual dispatch
1731/// outcome, never from anything the model said.
1732#[must_use]
1733pub fn approval_completed_text(label: &str, decider: &str, success: bool) -> String {
1734    if success {
1735        format!("✅ Approved by {decider} — \"{label}\" is done.")
1736    } else {
1737        format!("✅ Approved by {decider} — \"{label}\" ran but hit an error.")
1738    }
1739}
1740
1741/// The persisted outcome of an `ApprovalService.Respond` call.
1742///
1743/// On the THIN path the control plane is the signer: the caller submits an
1744/// UNSIGNED decision and the control plane appends a server-signed
1745/// `approval_response` event, returning the signature so the caller can show /
1746/// audit a verifiable outcome.
1747#[derive(Debug, Clone, PartialEq, Eq)]
1748pub struct ApprovalOutcome {
1749    /// `true` if a matching pending request existed and the response was
1750    /// persisted; `false` is the idempotent no-op (already answered / unknown).
1751    pub persisted: bool,
1752    /// Lowercase-hex ed25519 signature over the canonical response bytes.
1753    pub signature_hex: String,
1754    /// Lowercase-hex public key the signature verifies against.
1755    pub signed_by_hex: String,
1756}
1757
1758impl From<polyc_proto::proto::polychrome::approval::v1::ApprovalResponseReply> for ApprovalOutcome {
1759    fn from(reply: polyc_proto::proto::polychrome::approval::v1::ApprovalResponseReply) -> Self {
1760        Self {
1761            persisted: reply.persisted,
1762            signature_hex: reply.signature_hex,
1763            signed_by_hex: reply.signed_by_hex,
1764        }
1765    }
1766}
1767
1768/// One outstanding approval returned by [`ApprovalDialer::list_pending`].
1769///
1770/// Carries the fields an edge needs to re-render an Approve/Deny prompt for a
1771/// `request_id` it lost (e.g. the streamed `ApprovalPending` event never arrived).
1772#[derive(Debug, Clone, PartialEq, Eq)]
1773pub struct PendingApproval {
1774    /// Turn that emitted this occurrence of `request_id`.
1775    pub turn_id: String,
1776    /// The id to answer via [`ApprovalDialer::respond`].
1777    pub request_id: String,
1778    /// The raw tool name the approval gates.
1779    pub tool_name: String,
1780    /// Curated title preserved from the durable approval request. Empty only
1781    /// for a historical request that predates the field.
1782    pub title: String,
1783    /// The tool-call arguments (JSON), for rendering the prompt.
1784    pub args_json: String,
1785    /// The override gate reason the durable `approval_request` recorded — empty
1786    /// for an ordinary gated call, non-empty for the lethal-trifecta /
1787    /// Rule-of-Two containment override. Lets a recovered card show the same
1788    /// explanation the live `ApprovalPending` event carried.
1789    pub reason: String,
1790    /// Short-lived signed capability (`#787`), freshly minted for THIS
1791    /// listing and scoped to `turn_id` + `request_id` + the conversation it
1792    /// belongs to.
1793    /// Carry it back unmodified to [`ApprovalDialer::respond`].
1794    pub resolve_token: String,
1795    /// Computed-preview enrichment (`#1496`), read back from the durable
1796    /// `approval_request` event so a recovered card renders the exact
1797    /// preview the live one showed. `None` for every call but
1798    /// `routine_create`.
1799    pub preview: Option<ApprovalPreview>,
1800    /// Trusted-side preview for a read that crosses conversations.
1801    pub read_preview: Option<ReadPreview>,
1802    /// Whether this still-unanswered approval was already surfaced once (a
1803    /// later turn's boundary marker sits after its `approval_request` in the
1804    /// event log while it stayed unanswered). `false`: render the full
1805    /// interactive card; `true`: render a compact reminder instead (#1970).
1806    /// Derived by the control plane at listing time — never edge-cached.
1807    pub already_surfaced: bool,
1808}
1809
1810/// Converts every durable pending-approval field to the edge-owned model.
1811impl TryFrom<PendingApprovalEntry> for PendingApproval {
1812    type Error = DialError;
1813
1814    fn try_from(p: PendingApprovalEntry) -> Result<Self, Self::Error> {
1815        let PendingApprovalEntry {
1816            request_id,
1817            tool_name,
1818            args_json,
1819            reason,
1820            resolve_token,
1821            preview,
1822            read_preview,
1823            already_surfaced,
1824            turn_id,
1825            title,
1826            __buffa_unknown_fields: _,
1827        } = p;
1828        Ok(Self {
1829            turn_id,
1830            request_id,
1831            tool_name,
1832            title,
1833            args_json,
1834            reason,
1835            resolve_token,
1836            preview: preview.into_option().map(Into::into),
1837            read_preview: read_preview
1838                .into_option()
1839                .map(ReadPreview::try_from)
1840                .transpose()?,
1841            already_surfaced,
1842        })
1843    }
1844}
1845
1846/// A decision durably recorded but not yet consumed by tool execution.
1847#[derive(Debug, Clone, PartialEq, Eq)]
1848pub struct RecordedApprovalRecovery {
1849    /// Whether the saved decision approved the call.
1850    pub approved: bool,
1851}
1852
1853/// The recovery state for an approval occurrence.
1854#[derive(Debug, Clone, PartialEq, Eq)]
1855pub enum ApprovalRecoveryState {
1856    /// A verified decision is ready for a resume turn.
1857    DecisionRecorded(RecordedApprovalRecovery),
1858    /// The prior response was unverifiable, so the durable turn will re-prompt.
1859    RepromptRequired,
1860}
1861
1862/// One durable approval occurrence that needs a resume turn.
1863#[derive(Debug, Clone, PartialEq, Eq)]
1864pub struct RecoverableApproval {
1865    /// Turn that emitted this occurrence.
1866    pub turn_id: String,
1867    /// Request identifier within [`Self::turn_id`].
1868    pub request_id: String,
1869    /// Raw tool name.
1870    pub tool_name: String,
1871    /// Curated durable title, or empty for historic records.
1872    pub title: String,
1873    /// Full tool-call arguments JSON.
1874    pub args_json: String,
1875    /// Durable gating reason.
1876    pub reason: String,
1877    /// Routine preview, when the durable request carried one.
1878    pub preview: Option<ApprovalPreview>,
1879    /// Trusted-side preview for a read that crosses conversations.
1880    pub read_preview: Option<ReadPreview>,
1881    /// Exactly one recovery state.
1882    pub state: ApprovalRecoveryState,
1883}
1884
1885impl TryFrom<RecoverableApprovalEntry> for RecoverableApproval {
1886    type Error = DialError;
1887
1888    fn try_from(entry: RecoverableApprovalEntry) -> Result<Self, Self::Error> {
1889        let RecoverableApprovalEntry {
1890            turn_id,
1891            request_id,
1892            tool_name,
1893            title,
1894            args_json,
1895            reason,
1896            preview,
1897            read_preview,
1898            state,
1899            __buffa_unknown_fields: _,
1900        } = entry;
1901        let state = match state {
1902            Some(recoverable_approval_entry::State::DecisionRecorded(decision)) => {
1903                let WireRecordedApprovalDecision {
1904                    approved,
1905                    __buffa_unknown_fields: _,
1906                } = *decision;
1907                ApprovalRecoveryState::DecisionRecorded(RecordedApprovalRecovery { approved })
1908            }
1909            Some(recoverable_approval_entry::State::RepromptRequired(marker)) => {
1910                let WireRepromptRequired {
1911                    __buffa_unknown_fields: _,
1912                } = *marker;
1913                ApprovalRecoveryState::RepromptRequired
1914            }
1915            None => {
1916                return Err(DialError::InvalidApprovalRecovery(
1917                    "approval recovery entry omitted its state",
1918                ));
1919            }
1920        };
1921        Ok(Self {
1922            turn_id,
1923            request_id,
1924            tool_name,
1925            title,
1926            args_json,
1927            reason,
1928            preview: preview.into_option().map(Into::into),
1929            read_preview: read_preview
1930                .into_option()
1931                .map(ReadPreview::try_from)
1932                .transpose()?,
1933            state,
1934        })
1935    }
1936}
1937
1938/// Trusted-side preview for a read that crosses conversations.
1939#[derive(Debug, Clone, PartialEq, Eq)]
1940pub enum ReadPreview {
1941    /// A canonical search query and its resolved conversation count.
1942    Search {
1943        /// Query text that will actually execute.
1944        canonical_query: String,
1945        /// Number of conversations in the resolved scope.
1946        scope_count: u32,
1947    },
1948    /// One bounded read of a search hit.
1949    Hit {
1950        /// Query that produced the hit.
1951        origin_query: String,
1952        /// Human-readable conversation label.
1953        conversation_label: String,
1954        /// Maximum returned text size in bytes.
1955        max_bytes: u32,
1956    },
1957}
1958
1959impl TryFrom<WireListReadPreview> for ReadPreview {
1960    type Error = DialError;
1961
1962    fn try_from(preview: WireListReadPreview) -> Result<Self, Self::Error> {
1963        let WireListReadPreview {
1964            detail,
1965            __buffa_unknown_fields: _,
1966        } = preview;
1967        match detail {
1968            Some(wire_list_read_preview::Detail::Search(search)) => {
1969                let WireSearchPreview {
1970                    canonical_query,
1971                    scope_count,
1972                    __buffa_unknown_fields: _,
1973                } = *search;
1974                Ok(Self::Search {
1975                    canonical_query,
1976                    scope_count,
1977                })
1978            }
1979            Some(wire_list_read_preview::Detail::Hit(hit)) => {
1980                let WireSearchHitPreview {
1981                    origin_query,
1982                    conversation_label,
1983                    max_bytes,
1984                    __buffa_unknown_fields: _,
1985                } = *hit;
1986                Ok(Self::Hit {
1987                    origin_query,
1988                    conversation_label,
1989                    max_bytes,
1990                })
1991            }
1992            None => Err(DialError::InvalidApprovalRecovery(
1993                "approval read preview omitted its detail",
1994            )),
1995        }
1996    }
1997}
1998
1999/// One fire instant rendered for a human in [`ApprovalPreview`]: the same
2000/// real instant twice, RFC3339 in [`ApprovalPreview::zone_name`] and in UTC.
2001#[derive(Debug, Clone, PartialEq, Eq)]
2002pub struct ApprovalPreviewFire {
2003    /// RFC3339 instant in [`ApprovalPreview::zone_name`]'s local wall clock.
2004    pub local_time: String,
2005    /// The identical instant, RFC3339 in UTC. Carried for the audit path;
2006    /// [`approval_preview_text`] does not render it (`#1808`).
2007    pub utc_time: String,
2008    /// How this instant's zone reads to a human — `PDT (UTC-7)` — or EMPTY
2009    /// when [`ApprovalPreview::cadence`] already states it for every run.
2010    /// The control plane decides that placement (it holds both the schedule
2011    /// and the tz database); rendering it whenever it is non-empty is the
2012    /// whole of this side's job (`#1808`).
2013    pub zone_label: String,
2014}
2015
2016/// The approval card's spec-computed preview (`#1496`).
2017///
2018/// The next fire times a routine will run at, plus the exact prompt text that
2019/// will execute — built by the control plane from the compiled spec, never
2020/// from the model's narration (INV-RL3). Carried on both
2021/// [`TurnEvent::ApprovalPending`] (the live card) and [`PendingApproval`]
2022/// (the `ListPending` recovery path) so neither surface can drop it. Render
2023/// with [`approval_preview_text`] — never a hand-written per-edge rendering,
2024/// so the four chat edges cannot word the same state differently.
2025#[derive(Debug, Clone, PartialEq, Eq, Default)]
2026pub struct ApprovalPreview {
2027    /// The exact prompt text the routine will run, copied verbatim from the
2028    /// compiled spec — never re-typed or summarized.
2029    pub prompt_text: String,
2030    /// Up to the next three fire instants, oldest first. Fewer than three
2031    /// (or none) when the schedule itself has fewer left.
2032    pub next_fires: Vec<ApprovalPreviewFire>,
2033    /// IANA name of the zone `next_fires[].local_time` is rendered in.
2034    pub zone_name: String,
2035    /// `true` when [`Self::zone_name`] is the schedule's OWN zone shown as a
2036    /// labeled fallback because the creating admin's zone could not be
2037    /// resolved — `false` when it is the admin's actual zone.
2038    pub zone_is_fallback: bool,
2039    /// The schedule in English — `every weekday at 9:00 AM PDT (UTC-7)` —
2040    /// EMPTY when it is not confidently describable, in which case the card
2041    /// omits its cadence line rather than assert a wrong sentence (`#1808`).
2042    pub cadence: String,
2043    /// Routine resource name for a delete preview; empty for create.
2044    pub routine_name: String,
2045}
2046
2047/// Names every field so a field added on the agent-service wire fails to
2048/// compile here instead of silently defaulting (see CLAUDE.md's cross-type
2049/// conversion rule and #1241/#1238).
2050impl From<WireAgentApprovalPreviewFire> for ApprovalPreviewFire {
2051    fn from(f: WireAgentApprovalPreviewFire) -> Self {
2052        Self {
2053            local_time: f.local_time,
2054            utc_time: f.utc_time,
2055            zone_label: f.zone_label,
2056        }
2057    }
2058}
2059
2060/// Names every field so a field added on the approval-service wire fails to
2061/// compile here instead of silently defaulting (see CLAUDE.md's cross-type
2062/// conversion rule and #1241/#1238).
2063impl From<WireListApprovalPreviewFire> for ApprovalPreviewFire {
2064    fn from(f: WireListApprovalPreviewFire) -> Self {
2065        Self {
2066            local_time: f.local_time,
2067            utc_time: f.utc_time,
2068            zone_label: f.zone_label,
2069        }
2070    }
2071}
2072
2073impl From<WireAgentApprovalPreview> for ApprovalPreview {
2074    fn from(p: WireAgentApprovalPreview) -> Self {
2075        let WireAgentApprovalPreview {
2076            prompt_text,
2077            next_fires,
2078            zone_name,
2079            zone_is_fallback,
2080            cadence,
2081            routine_name,
2082            __buffa_unknown_fields: _,
2083        } = p;
2084        Self {
2085            prompt_text,
2086            next_fires: next_fires.into_iter().map(Into::into).collect(),
2087            zone_name,
2088            zone_is_fallback,
2089            cadence,
2090            routine_name,
2091        }
2092    }
2093}
2094
2095impl From<WireListApprovalPreview> for ApprovalPreview {
2096    fn from(p: WireListApprovalPreview) -> Self {
2097        let WireListApprovalPreview {
2098            prompt_text,
2099            next_fires,
2100            zone_name,
2101            zone_is_fallback,
2102            cadence,
2103            routine_name,
2104            __buffa_unknown_fields: _,
2105        } = p;
2106        Self {
2107            prompt_text,
2108            next_fires: next_fires.into_iter().map(Into::into).collect(),
2109            zone_name,
2110            zone_is_fallback,
2111            cadence,
2112            routine_name,
2113        }
2114    }
2115}
2116
2117/// Render an [`ApprovalPreview`] as the text block every chat edge appends to
2118/// an approval card (`#1496`, reformatted in `#1808`).
2119///
2120/// The ONE shared helper, so the four edges cannot word the same state
2121/// differently (this repo's house rule; see
2122/// `humanize_tool_name`/`tool_status_text` for the same pattern elsewhere).
2123/// Sentence case, no jargon, no destination line (the pivot dropped it — see
2124/// `docs/specifications/invariants-routine-lifecycle.md`).
2125///
2126/// The shape a reader gets, with the run table wrapped in a code fence:
2127///
2128/// ```text
2129/// This routine will run:
2130///
2131/// Post the daily standup summary.
2132///
2133/// Runs every weekday at 9:00 AM PDT (UTC-7)
2134///
2135/// Next 3 runs
2136///   Wed Jul 29    9:00 AM    tomorrow
2137///   Thu Jul 30    9:00 AM    in 2 days
2138///   Fri Jul 31    9:00 AM    in 3 days
2139/// ```
2140///
2141/// Three decisions worth knowing:
2142///
2143/// - **The cadence line states what the list cannot.** Three instants make a
2144///   reader infer "weekdays at 9" by diffing dates, and the recurrence is the
2145///   fact actually being approved. It is omitted entirely when the control
2146///   plane could not describe the schedule confidently — never guessed at.
2147/// - **The title counts the runs it actually has** ("Next 3 runs", "Next
2148///   run"), because a schedule can yield fewer than three.
2149/// - **The rows sit in a fenced block** so their columns align on a surface
2150///   that renders proportional text. The chat edges render fences natively;
2151///   the email edge strips the markers, which is markup, not wording.
2152///
2153/// `now` is caller-injected rather than read from the clock, matching this
2154/// workspace's other pure renderers. It anchors each run relatively
2155/// ("tomorrow", "in 2 days"), which matters on the `ListPending` recovery
2156/// path: that card is rebuilt from a preview frozen in the durable
2157/// `approval_request` event, so a stale "tomorrow" beside an absolute date
2158/// would mislead in a way a merely old date does not. A run already behind
2159/// `now` reads `passed`.
2160///
2161/// The zone-fallback disclosure is spelled out whenever
2162/// `preview.zone_is_fallback` is set, and keeps the IANA zone name: an
2163/// abbreviation cannot tell a reader WHICH zone got guessed, which is exactly
2164/// what they are checking there.
2165#[must_use]
2166pub fn approval_preview_text(preview: &ApprovalPreview, now: DateTime<Utc>) -> String {
2167    let mut lines = vec![
2168        "This routine will run:".to_owned(),
2169        String::new(),
2170        preview.prompt_text.clone(),
2171    ];
2172    if !preview.cadence.is_empty() {
2173        lines.push(String::new());
2174        lines.push(format!("Runs {}", preview.cadence));
2175    }
2176    lines.push(String::new());
2177    if preview.next_fires.is_empty() {
2178        lines.push(
2179            "This schedule has no upcoming runs, so approving it won't run anything.".to_owned(),
2180        );
2181    } else {
2182        lines.push(run_list_title(preview.next_fires.len()));
2183        lines.push(FENCE.to_owned());
2184        lines.extend(run_rows(&preview.next_fires, now));
2185        lines.push(FENCE.to_owned());
2186    }
2187    if preview.zone_is_fallback {
2188        lines.push(String::new());
2189        lines.push(format!(
2190            "We don't know your time zone yet, so these times are in the routine's own zone ({}).",
2191            preview.zone_name
2192        ));
2193    }
2194    lines.join("\n")
2195}
2196
2197/// Render the shared approval preview without chat-markup fences.
2198///
2199/// Browser cards already place this text in a monospace block, so exposing the
2200/// literal Markdown fence would show implementation markup to the reader. The
2201/// wording and aligned rows remain shared with [`approval_preview_text`].
2202#[must_use]
2203pub fn approval_preview_plain_text(preview: &ApprovalPreview, now: DateTime<Utc>) -> String {
2204    approval_preview_text(preview, now)
2205        .lines()
2206        .filter(|line| *line != FENCE)
2207        .collect::<Vec<_>>()
2208        .join("\n")
2209}
2210
2211/// The fence that wraps the run table. The chat edges that render fences
2212/// natively show it as a monospace block directly, and the chat edge that
2213/// speaks HTML converts it to `<pre>` on the way out — so one shared string
2214/// aligns on all of them.
2215const FENCE: &str = "```";
2216
2217/// Title the run list by the number of runs it actually holds.
2218///
2219/// A schedule can yield fewer than three (a `once` schedule yields one; an
2220/// exhausted one yields none), so the count is derived, never assumed —
2221/// "Next 3 runs" beside two rows would misstate the schedule.
2222fn run_list_title(count: usize) -> String {
2223    if count == 1 {
2224        "Next run".to_owned()
2225    } else {
2226        format!("Next {count} runs")
2227    }
2228}
2229
2230/// One run's three rendered cells: the day, the clock time (carrying the
2231/// zone label when this run owns it), and how far off it is.
2232struct RunRow {
2233    day: String,
2234    time: String,
2235    relative: String,
2236}
2237
2238/// Lay the runs out as aligned `day / time / how-far-off` columns.
2239///
2240/// Column widths come from the widest cell present, so the table stays snug
2241/// whether or not the runs carry their own zone labels.
2242fn run_rows(fires: &[ApprovalPreviewFire], now: DateTime<Utc>) -> Vec<String> {
2243    let rows: Vec<RunRow> = fires.iter().map(|fire| run_cells(fire, now)).collect();
2244    let day_width = rows
2245        .iter()
2246        .map(|row| row.day.chars().count())
2247        .max()
2248        .unwrap_or(0);
2249    let time_width = rows
2250        .iter()
2251        .map(|row| row.time.chars().count())
2252        .max()
2253        .unwrap_or(0);
2254    rows.iter()
2255        .map(|row| {
2256            format!(
2257                "  {:day_width$}    {:time_width$}    {}",
2258                row.day, row.time, row.relative
2259            )
2260        })
2261        .collect()
2262}
2263
2264/// One run's cells: the day, the clock time (carrying the zone label when
2265/// this run owns it), and how far off it is.
2266///
2267/// A `local_time` that does not parse falls back to the raw instant in the
2268/// day cell rather than dropping the run — a card that silently lists two of
2269/// three runs would understate the schedule.
2270fn run_cells(fire: &ApprovalPreviewFire, now: DateTime<Utc>) -> RunRow {
2271    let Ok(local) = DateTime::parse_from_rfc3339(&fire.local_time) else {
2272        return RunRow {
2273            day: fire.local_time.clone(),
2274            time: String::new(),
2275            relative: String::new(),
2276        };
2277    };
2278    // Compare in the run's own offset so "today" means the reader's calendar
2279    // day, not UTC's — those disagree for most of the world for part of each
2280    // day, and disagreeing here is exactly the mistake this column exists to
2281    // prevent.
2282    let now_local = now.with_timezone(local.offset());
2283    let day = if local.year() == now_local.year() {
2284        local.format("%a %b %e").to_string()
2285    } else {
2286        local.format("%a %b %e, %Y").to_string()
2287    };
2288    let mut time = local.format("%-I:%M %p").to_string();
2289    if !fire.zone_label.is_empty() {
2290        time.push(' ');
2291        time.push_str(&fire.zone_label);
2292    }
2293    RunRow {
2294        day,
2295        time,
2296        relative: relative_day(local, now_local),
2297    }
2298}
2299
2300/// How far off a run is, in the reader's own calendar days.
2301fn relative_day(local: DateTime<FixedOffset>, now_local: DateTime<FixedOffset>) -> String {
2302    if local < now_local {
2303        return "passed".to_owned();
2304    }
2305    match (local.date_naive() - now_local.date_naive()).num_days() {
2306        0 => "today".to_owned(),
2307        1 => "tomorrow".to_owned(),
2308        days => format!("in {days} days"),
2309    }
2310}
2311
2312/// Reusable handle for the control plane's `ApprovalService`.
2313///
2314/// The THIN human-in-the-loop path. Shares the `AgentService` endpoint — both
2315/// are served on one Connect port — so it is built from the same address.
2316#[derive(Clone)]
2317pub struct ApprovalDialer {
2318    client: Arc<ApprovalServiceClient<HttpClient>>,
2319    /// Set only by [`Self::with_credentials`]. `None` (the [`Self::new`] and
2320    /// [`Self::with_bearer`] paths) means [`Self::respond`] asserts no
2321    /// responder — there is no key to sign one with, and an unprovable
2322    /// responder is treated as an absent one (`#1553`).
2323    credentials: Option<Arc<EdgeCredentials>>,
2324}
2325
2326impl ApprovalDialer {
2327    /// Build a dialer pointed at `addr` (expects `http://host:port`).
2328    ///
2329    /// # Errors
2330    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI.
2331    pub fn new(addr: &str) -> Result<Self, DialError> {
2332        Ok(Self {
2333            client: build_control_client(addr, None, ApprovalServiceClient::new)?,
2334            credentials: None,
2335        })
2336    }
2337
2338    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
2339    ///
2340    /// Every call this dialer makes carries an `Authorization: Bearer
2341    /// <bearer>` header. `ApprovalService` doesn't send `AgentStart`, so no
2342    /// signed [`AssertedAttribution`] envelope rides these calls — bearer-only.
2343    /// A bearer alone cannot assert who resolved an approval; a dialer that
2344    /// also holds its edge's signing key uses [`Self::with_credentials`].
2345    ///
2346    /// # Errors
2347    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
2348    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
2349    /// header value.
2350    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
2351        Ok(Self {
2352            client: build_control_client(addr, Some(bearer), ApprovalServiceClient::new)?,
2353            credentials: None,
2354        })
2355    }
2356
2357    /// Build a dialer pointed at `addr`, authenticated with `credentials`.
2358    ///
2359    /// Rides `credentials.bearer()` as the `Authorization` header, exactly
2360    /// like [`Self::with_bearer`], and additionally signs an
2361    /// `AssertedApproval` onto every [`Self::respond`] that names a responder
2362    /// (`#1553`) — one implementation every edge inherits, rather than one
2363    /// per surface.
2364    ///
2365    /// Takes an `Arc` because these are the SAME credentials the edge's
2366    /// [`AgentDialer`] holds: one edge, one identity key, whether it is
2367    /// dispatching a turn or asserting who approved one.
2368    /// [`AgentDialer::approval_dialer_with_credentials`] is the usual way to
2369    /// build this.
2370    ///
2371    /// # Errors
2372    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
2373    /// [`DialError::InvalidBearer`] if the bearer can't be encoded as an HTTP
2374    /// header value.
2375    pub fn with_credentials(
2376        addr: &str,
2377        credentials: Arc<EdgeCredentials>,
2378    ) -> Result<Self, DialError> {
2379        let client =
2380            build_control_client(addr, Some(credentials.bearer()), ApprovalServiceClient::new)?;
2381        Ok(Self {
2382            client,
2383            credentials: Some(credentials),
2384        })
2385    }
2386
2387    /// Submit a human's decision for a pending approval. The decision is
2388    /// UNSIGNED — the control plane signs and persists it (THIN path) and
2389    /// returns the signature. Idempotent: answering an already-decided or
2390    /// unknown (`turn_id`, `request_id`) occurrence returns `persisted: false`.
2391    ///
2392    /// When `approved_for_session` is true (and `approved` is true), the
2393    /// control plane remembers the decision for the paused turn's caller and
2394    /// auto-approves that caller's later identical, idempotent tool calls for
2395    /// the rest of the session ("approve & don't ask again"). Ignored on a
2396    /// denial.
2397    ///
2398    /// When `abort` is true (only meaningful with `approved = false`), this is
2399    /// an abort: the call is declined AND the caller should stop the turn (not
2400    /// re-drive it). A plain denial (`approved = false`, `abort = false`)
2401    /// declines the call but lets the turn continue. See [`ApprovalChoice`],
2402    /// which maps a button decision to these flags.
2403    ///
2404    /// `resolve_token` is the short-lived signed capability (`#787`) carried
2405    /// unmodified off the [`TurnEvent::ApprovalPending`] event or
2406    /// [`PendingApproval`] entry this decision answers — required: the
2407    /// control plane rejects a `Respond` whose token is missing, expired, or
2408    /// bound to a different turn, request, or conversation.
2409    ///
2410    /// `responder` is the identity of the human answering (`#68`), as this
2411    /// edge's own provider verified it. A dialer built by
2412    /// [`Self::with_credentials`] signs it into the request as an
2413    /// `AssertedApproval` (`#1553`); the control plane enforces a turn's
2414    /// required approver (`IngressDirective.required_approver`) against the
2415    /// verified identity alone, so Approve of such a turn is refused unless
2416    /// the assertion verifies AND matches.
2417    ///
2418    /// Asserting nothing is a supported state, not an error: an edge that
2419    /// cannot attribute the responder passes `None`, a machine decision
2420    /// carries none, and a dialer without credentials has no key to sign one
2421    /// with. Deny and Defer are unaffected either way — refusing is safe
2422    /// regardless of who refuses — and so is Approve of a turn that names no
2423    /// required approver.
2424    ///
2425    /// # Errors
2426    /// Returns [`DialError::Connect`] for any transport/encoding error
2427    /// (including the control plane's rejection of an invalid
2428    /// `resolve_token`, or a `permission_denied` when the verified responder
2429    /// doesn't match the turn's required approver).
2430    #[allow(clippy::too_many_arguments)] // each is a distinct field of the decision
2431    pub async fn respond(
2432        &self,
2433        turn_id: &str,
2434        request_id: &str,
2435        choice: ApprovalChoice,
2436        reason: &str,
2437        conversation_id: &str,
2438        modified_args_json: &str,
2439        injected_context: &str,
2440        resolve_token: &str,
2441        responder: Option<ExternalIdentity>,
2442    ) -> Result<ApprovalOutcome, DialError> {
2443        let request = self.respond_request(
2444            turn_id,
2445            request_id,
2446            choice,
2447            reason,
2448            conversation_id,
2449            modified_args_json,
2450            injected_context,
2451            resolve_token,
2452            responder,
2453        );
2454        let reply = self
2455            .client
2456            .respond_with_options(request, traced_options())
2457            .await?
2458            .into_owned();
2459        Ok(reply.into())
2460    }
2461
2462    /// Build the [`ApprovalResponseRequest`] one [`Self::respond`] call sends,
2463    /// signing the responder into it when there is a responder to assert and a
2464    /// credential to assert it with (`#1553`).
2465    ///
2466    /// The assertion is attached LAST, and that ordering is load-bearing: its
2467    /// signature covers the whole request — the decision, an approve's
2468    /// modified arguments and injected context, the occurrence identity, and
2469    /// the `resolve_token` — so any field set after the attach would fall
2470    /// outside the signature it was supposed to be covered by, and the control
2471    /// plane would reject the assertion as unverifiable. Everything else about
2472    /// the request is therefore final before the last statement runs.
2473    ///
2474    /// A dialer built by [`Self::new`] or [`Self::with_bearer`] holds no
2475    /// credentials, and `responder` is `None` for an edge that cannot
2476    /// attribute the human (or for a machine decision). Either way the request
2477    /// carries no assertion, which the control plane treats exactly as it
2478    /// treats one that fails to verify — see
2479    /// `docs/reference/verified-approver.md`, decision 4.
2480    // One builder carrying the RPC's full field set, mirroring `build_request`
2481    // for a turn; each argument is a distinct field of the decision, and
2482    // routing them through a params struct would only add one indirection over
2483    // the same fields.
2484    #[allow(clippy::too_many_arguments)]
2485    fn respond_request(
2486        &self,
2487        turn_id: &str,
2488        request_id: &str,
2489        choice: ApprovalChoice,
2490        reason: &str,
2491        conversation_id: &str,
2492        modified_args_json: &str,
2493        injected_context: &str,
2494        resolve_token: &str,
2495        responder: Option<ExternalIdentity>,
2496    ) -> ApprovalResponseRequest {
2497        use polyc_proto::proto::polychrome::approval::v1::{
2498            Approve, Defer, Deny, approval_response_request::Decision,
2499        };
2500        // Map the choice into the structured `oneof` (#67). An approval carries
2501        // the approver's optional edit + injected context; a denial carries its
2502        // reason + abort hint; a defer ("send back") carries only its reason.
2503        let decision = if choice.is_defer() {
2504            Decision::Defer(Box::new(Defer {
2505                reason: reason.to_owned(),
2506                __buffa_unknown_fields: buffa::UnknownFields::default(),
2507            }))
2508        } else if choice.approved() {
2509            Decision::Approve(Box::new(Approve {
2510                modified_args_json: modified_args_json.to_owned(),
2511                injected_context: injected_context.to_owned(),
2512                reason: reason.to_owned(),
2513                approved_for_session: choice.approved_for_session(),
2514                __buffa_unknown_fields: buffa::UnknownFields::default(),
2515            }))
2516        } else {
2517            Decision::Deny(Box::new(Deny {
2518                reason: reason.to_owned(),
2519                abort: choice.is_abort(),
2520                __buffa_unknown_fields: buffa::UnknownFields::default(),
2521            }))
2522        };
2523        let mut request = ApprovalResponseRequest {
2524            turn_id: turn_id.to_owned(),
2525            request_id: request_id.to_owned(),
2526            conversation_id: conversation_id.to_owned(),
2527            decision: Some(decision),
2528            resolve_token: resolve_token.to_owned(),
2529            asserted_approval: buffa::MessageField::none(),
2530            ..Default::default()
2531        };
2532        // Last, over the finished request.
2533        if let (Some(responder), Some(creds)) = (responder, self.credentials.as_deref()) {
2534            creds.attach_approval_assertion(&mut request, responder);
2535        }
2536        request
2537    }
2538
2539    /// List the conversation's outstanding approvals (a request with no later
2540    /// response). An edge calls this to recover `request_id`(s) it must prompt
2541    /// on after losing the streamed `ApprovalPending` event — turning a silent
2542    /// hang into a recoverable state. Read-only and idempotent.
2543    ///
2544    /// # Errors
2545    /// Returns [`DialError::Connect`] for any transport/encoding error.
2546    pub async fn list_pending(
2547        &self,
2548        conversation_id: &str,
2549    ) -> Result<Vec<PendingApproval>, DialError> {
2550        let mut pending = Vec::new();
2551        let mut page_token = String::new();
2552        loop {
2553            let request = ListPendingRequest {
2554                conversation_id: conversation_id.to_owned(),
2555                page_token: page_token.clone(),
2556                ..Default::default()
2557            };
2558            let reply = self
2559                .client
2560                .list_pending_with_options(request, traced_options())
2561                .await?
2562                .into_owned();
2563            pending.extend(
2564                reply
2565                    .pending
2566                    .into_iter()
2567                    .map(PendingApproval::try_from)
2568                    .collect::<Result<Vec<_>, _>>()?,
2569            );
2570            if reply.next_page_token.is_empty() {
2571                break;
2572            }
2573            page_token = reply.next_page_token;
2574        }
2575        Ok(pending)
2576    }
2577
2578    /// List saved approval decisions that need their paused turn resumed.
2579    ///
2580    /// Unlike [`Self::list_pending`], these entries intentionally carry no
2581    /// resolve capability: a decision has already been recorded, or the
2582    /// control plane must re-prompt it durably. A malformed repeated page token
2583    /// is refused rather than allowing a caller's recovery loop to spin.
2584    ///
2585    /// # Errors
2586    /// Returns [`DialError::Connect`] for transport failures and
2587    /// [`DialError::InvalidApprovalRecovery`] when pagination repeats a token.
2588    pub async fn list_recovery(
2589        &self,
2590        conversation_id: &str,
2591    ) -> Result<Vec<RecoverableApproval>, DialError> {
2592        let mut recovery = Vec::new();
2593        let mut page_token = String::new();
2594        let mut seen_page_tokens = std::collections::HashSet::new();
2595        loop {
2596            let request = ListApprovalRecoveryRequest {
2597                conversation_id: conversation_id.to_owned(),
2598                page_size: 0,
2599                page_token: page_token.clone(),
2600                __buffa_unknown_fields: buffa::UnknownFields::default(),
2601            };
2602            let reply = self
2603                .client
2604                .list_recovery_with_options(request, traced_options())
2605                .await?
2606                .into_owned();
2607            recovery.extend(
2608                reply
2609                    .recovery
2610                    .into_iter()
2611                    .map(RecoverableApproval::try_from)
2612                    .collect::<Result<Vec<_>, _>>()?,
2613            );
2614            if reply.next_page_token.is_empty() {
2615                break;
2616            }
2617            if !seen_page_tokens.insert(reply.next_page_token.clone()) {
2618                return Err(DialError::InvalidApprovalRecovery(
2619                    "recovery listing repeated a continuation token",
2620                ));
2621            }
2622            page_token = reply.next_page_token;
2623        }
2624        Ok(recovery)
2625    }
2626
2627    /// Remove outside content from a conversation (`#590`) so its web and
2628    /// outside access recover. Admin-gated server-side; the control plane
2629    /// verifies `actor` holds the admin role, appends the signed removal
2630    /// record, and reports which journal positions left the working context.
2631    ///
2632    /// `positions` names specific entries; `all_quarantined` removes every
2633    /// entry currently carrying outside content (`positions` is then
2634    /// ignored). `source_only` limits the removal to the named entries — the
2635    /// default also removes what the agent produced after them, which is the
2636    /// safer posture when the content may have steered it.
2637    ///
2638    /// # Errors
2639    /// Returns [`DialError::Connect`] for any transport/encoding error, a
2640    /// permission refusal, or an invalid position.
2641    pub async fn excise_taint(
2642        &self,
2643        conversation_id: &str,
2644        positions: &[u64],
2645        all_quarantined: bool,
2646        source_only: bool,
2647        reason: &str,
2648        actor: polyc_proto::proto::polychrome::persona::v1::ExternalIdentity,
2649    ) -> Result<ExcisionOutcome, DialError> {
2650        use polyc_proto::proto::polychrome::approval::v1::ExciseTaintRequest;
2651        let request = ExciseTaintRequest {
2652            conversation_id: conversation_id.to_owned(),
2653            positions: positions.to_vec(),
2654            all_quarantined,
2655            source_only,
2656            reason: reason.to_owned(),
2657            actor: buffa::MessageField::some(actor),
2658            ..Default::default()
2659        };
2660        let reply = self
2661            .client
2662            .excise_taint_with_options(request, traced_options())
2663            .await?
2664            .into_owned();
2665        Ok(reply.into())
2666    }
2667
2668    /// Replay a recorded conversation against its committed event log (`#690`):
2669    /// re-execute its committed turns in process — no live model, no live tools,
2670    /// nothing sent or spent again — and return a per-turn verdict. Read-only:
2671    /// the control plane appends nothing and takes no writer lease, so it is safe
2672    /// to run on production data any number of times.
2673    ///
2674    /// Admin-gated (`#694`): the control plane verifies `actor` holds the admin
2675    /// role — the same gate `excise_taint` applies — before it reads or replays
2676    /// anything, so a non-admin (or absent) actor is refused.
2677    ///
2678    /// `from`/`to` bound the 0-based committed-turn ordinals (inclusive); `None`
2679    /// means the ends. A set `over` selects what-if mode (fork one recorded step
2680    /// and report where the conversation diverges); `None` is a plain verify
2681    /// (assert every turn reproduces its record).
2682    ///
2683    /// # Errors
2684    /// Returns [`DialError::Connect`] for any transport/encoding error, or a
2685    /// permission refusal when `actor` is not an admin.
2686    pub async fn replay_conversation(
2687        &self,
2688        conversation_id: &str,
2689        from: Option<usize>,
2690        to: Option<usize>,
2691        over: Option<ReplayOverrideSpec>,
2692        actor: polyc_proto::proto::polychrome::persona::v1::ExternalIdentity,
2693    ) -> Result<ReplayReport, DialError> {
2694        use polyc_proto::proto::polychrome::approval::v1::{
2695            ReplayConversationRequest, ReplayOverride,
2696        };
2697        let override_msg = over.map(ReplayOverride::from);
2698        // An absent bound is the `-1` sentinel; the control plane maps a negative
2699        // ordinal to "the ends".
2700        let to_bound = |v: Option<usize>| v.and_then(|n| i64::try_from(n).ok()).unwrap_or(-1);
2701        let request = ReplayConversationRequest {
2702            conversation_id: conversation_id.to_owned(),
2703            from: to_bound(from),
2704            to: to_bound(to),
2705            r#override: override_msg
2706                .map_or_else(buffa::MessageField::none, buffa::MessageField::some),
2707            actor: buffa::MessageField::some(actor),
2708            ..Default::default()
2709        };
2710        let reply = self
2711            .client
2712            .replay_conversation_with_options(request, traced_options())
2713            .await?
2714            .into_owned();
2715        let turns = reply
2716            .turns
2717            .into_iter()
2718            .map(ReplayTurnVerdict::from)
2719            .collect();
2720        Ok(ReplayReport {
2721            turns,
2722            all_match: reply.all_match,
2723        })
2724    }
2725
2726    /// Verify a conversation's tamper-evidence (`#799`): replay its event log
2727    /// and check every signed MMR root recorded along the way against a
2728    /// freshly rebuilt tree. Read-only — appends nothing.
2729    ///
2730    /// Admin-gated server-side, the same gate `excise_taint` uses.
2731    ///
2732    /// # Errors
2733    /// Returns [`DialError::Connect`] for any transport/encoding error, or a
2734    /// permission refusal when `actor` is not an admin.
2735    pub async fn verify_conversation(
2736        &self,
2737        conversation_id: &str,
2738        actor: polyc_proto::proto::polychrome::persona::v1::ExternalIdentity,
2739    ) -> Result<VerificationOutcome, DialError> {
2740        use polyc_proto::proto::polychrome::approval::v1::VerifyConversationRequest;
2741        let request = VerifyConversationRequest {
2742            conversation_id: conversation_id.to_owned(),
2743            actor: buffa::MessageField::some(actor),
2744            ..Default::default()
2745        };
2746        let reply = self
2747            .client
2748            .verify_conversation_with_options(request, traced_options())
2749            .await?
2750            .into_owned();
2751        Ok(reply.into())
2752    }
2753
2754    /// Repair a conversation's event log (`#799`, the `conversation repair`
2755    /// quarantine verb): replay every position independently so one
2756    /// corrupted item cannot block the rest, then rewrite the partition
2757    /// retaining only what decoded. A partition with nothing corrupted is a
2758    /// no-op (empty quarantine list).
2759    ///
2760    /// Admin-gated server-side, the same gate `excise_taint` uses.
2761    ///
2762    /// # Errors
2763    /// Returns [`DialError::Connect`] for any transport/encoding error, or a
2764    /// permission refusal when `actor` is not an admin.
2765    pub async fn repair_conversation(
2766        &self,
2767        conversation_id: &str,
2768        actor: polyc_proto::proto::polychrome::persona::v1::ExternalIdentity,
2769        operation_id: &str,
2770    ) -> Result<Vec<u64>, DialError> {
2771        use polyc_proto::proto::polychrome::approval::v1::RepairConversationRequest;
2772        let request = RepairConversationRequest {
2773            conversation_id: conversation_id.to_owned(),
2774            actor: buffa::MessageField::some(actor),
2775            operation_id: operation_id.to_owned(),
2776            ..Default::default()
2777        };
2778        let reply = self
2779            .client
2780            .repair_conversation_with_options(request, traced_options())
2781            .await?
2782            .into_owned();
2783        Ok(reply.quarantined_positions)
2784    }
2785}
2786
2787/// One counterfactual override for a what-if [`ApprovalDialer::replay_conversation`].
2788///
2789/// Mirrors the control plane's replay override so a `conversation replay
2790/// --what-if` command names an override without depending on `polyc-proto`.
2791#[derive(Debug, Clone, PartialEq, Eq)]
2792pub enum ReplayOverrideSpec {
2793    /// Replace turn `turn`'s first recorded completion text.
2794    Completion {
2795        /// 0-based committed-turn ordinal to fork at.
2796        turn: u32,
2797        /// The counterfactual completion text.
2798        text: String,
2799    },
2800    /// Replace turn `turn`'s `index`-th recorded tool result.
2801    ToolResult {
2802        /// 0-based committed-turn ordinal to fork at.
2803        turn: u32,
2804        /// Which recorded tool result (in call order) to replace.
2805        index: u32,
2806        /// The counterfactual tool result JSON.
2807        result_json: String,
2808    },
2809}
2810
2811/// Every field named explicitly — no `..Default::default()` spread on any of
2812/// the three wire messages this builds — so a field added to any of them
2813/// without updating this impl fails to compile instead of silently not
2814/// riding the wire.
2815impl From<ReplayOverrideSpec> for polyc_proto::proto::polychrome::approval::v1::ReplayOverride {
2816    fn from(spec: ReplayOverrideSpec) -> Self {
2817        use polyc_proto::proto::polychrome::approval::v1::{
2818            ReplayCompletionOverride, ReplayToolResultOverride, replay_override::Kind,
2819        };
2820        let kind = match spec {
2821            ReplayOverrideSpec::Completion { turn, text } => {
2822                Kind::Completion(Box::new(ReplayCompletionOverride {
2823                    turn,
2824                    text,
2825                    __buffa_unknown_fields: buffa::UnknownFields::default(),
2826                }))
2827            }
2828            ReplayOverrideSpec::ToolResult {
2829                turn,
2830                index,
2831                result_json,
2832            } => Kind::ToolResult(Box::new(ReplayToolResultOverride {
2833                turn,
2834                index,
2835                result_json,
2836                __buffa_unknown_fields: buffa::UnknownFields::default(),
2837            })),
2838        };
2839        Self {
2840            kind: Some(kind),
2841            __buffa_unknown_fields: buffa::UnknownFields::default(),
2842        }
2843    }
2844}
2845
2846/// How one replayed turn compared against its record.
2847#[derive(Debug, Clone, PartialEq, Eq)]
2848pub enum ReplayTurnOutcome {
2849    /// Every compared field reproduced the record.
2850    Match,
2851    /// The turn diverged from the record (verify) or forked from it (what-if) at
2852    /// `field`, with a human-readable `detail`.
2853    Diverged {
2854        /// The first field that differs (e.g. `"messages"`, `"usage"`, `"stop"`).
2855        field: String,
2856        /// The divergence explanation.
2857        detail: String,
2858    },
2859    /// The turn's recorded inputs are incomplete (excised or malformed), so it
2860    /// cannot be faithfully reproduced — reported, never passed.
2861    Unreplayable {
2862        /// Why the turn could not be reproduced.
2863        reason: String,
2864    },
2865}
2866
2867/// One turn's replay verdict, as [`ApprovalDialer::replay_conversation`] returns it.
2868#[derive(Debug, Clone, PartialEq, Eq)]
2869pub struct ReplayTurnVerdict {
2870    /// The turn's 0-based committed ordinal.
2871    pub turn_index: u32,
2872    /// How the turn compared against its record.
2873    pub outcome: ReplayTurnOutcome,
2874}
2875
2876/// Total over the wire `ReplayOutcome` enum — an unknown/unspecified value
2877/// maps to [`ReplayTurnOutcome::Unreplayable`] rather than being dropped, so
2878/// this is a `From`, not a `TryFrom`.
2879impl From<polyc_proto::proto::polychrome::approval::v1::ReplayTurnVerdict> for ReplayTurnVerdict {
2880    fn from(v: polyc_proto::proto::polychrome::approval::v1::ReplayTurnVerdict) -> Self {
2881        use polyc_proto::proto::polychrome::approval::v1::ReplayOutcome;
2882        let outcome = match v.outcome.as_known() {
2883            Some(ReplayOutcome::Match) => ReplayTurnOutcome::Match,
2884            Some(ReplayOutcome::Diverged) => ReplayTurnOutcome::Diverged {
2885                field: v.field,
2886                detail: v.detail,
2887            },
2888            Some(ReplayOutcome::Unreplayable) => {
2889                ReplayTurnOutcome::Unreplayable { reason: v.detail }
2890            }
2891            Some(ReplayOutcome::Unspecified) | None => ReplayTurnOutcome::Unreplayable {
2892                reason: "the control plane returned an unknown replay outcome".to_owned(),
2893            },
2894        };
2895        Self {
2896            turn_index: v.turn_index,
2897            outcome,
2898        }
2899    }
2900}
2901
2902/// The whole-conversation replay report a `conversation replay` command renders.
2903#[derive(Debug, Clone, PartialEq, Eq)]
2904pub struct ReplayReport {
2905    /// Per-turn verdicts, in recorded order.
2906    pub turns: Vec<ReplayTurnVerdict>,
2907    /// True only when every replayed turn matched its record (the verify pass);
2908    /// false on any divergence or unreplayable turn.
2909    pub all_match: bool,
2910}
2911
2912/// Result of an [`ApprovalDialer::excise_taint`] call.
2913#[derive(Debug, Clone, PartialEq, Eq)]
2914pub struct ExcisionOutcome {
2915    /// Whether a removal record was durably appended (`false` = idempotent
2916    /// no-op: nothing was left to remove).
2917    pub persisted: bool,
2918    /// The journal positions that left the working context, after scope
2919    /// expansion.
2920    pub excised_positions: Vec<u64>,
2921    /// Hex signature over the removal record.
2922    pub signature_hex: String,
2923    /// Hex public key of the signer.
2924    pub signed_by_hex: String,
2925}
2926
2927impl From<polyc_proto::proto::polychrome::approval::v1::ExciseTaintReply> for ExcisionOutcome {
2928    fn from(reply: polyc_proto::proto::polychrome::approval::v1::ExciseTaintReply) -> Self {
2929        Self {
2930            persisted: reply.persisted,
2931            excised_positions: reply.excised_positions,
2932            signature_hex: reply.signature_hex,
2933            signed_by_hex: reply.signed_by_hex,
2934        }
2935    }
2936}
2937
2938/// Result of an [`ApprovalDialer::verify_conversation`] call (`#799`).
2939#[derive(Debug, Clone, PartialEq, Eq)]
2940pub struct VerificationOutcome {
2941    /// Whether every signed root in the log matched what replay recomputed.
2942    pub verified: bool,
2943    /// Human-readable detail on the first violation found; empty when
2944    /// `verified` is true.
2945    pub violation: String,
2946    /// Number of events the (successful) replay read.
2947    pub event_count: u64,
2948}
2949
2950impl From<polyc_proto::proto::polychrome::approval::v1::VerifyConversationReply>
2951    for VerificationOutcome
2952{
2953    fn from(reply: polyc_proto::proto::polychrome::approval::v1::VerifyConversationReply) -> Self {
2954        Self {
2955            verified: reply.verified,
2956            violation: reply.violation,
2957            event_count: reply.event_count,
2958        }
2959    }
2960}
2961
2962/// What one [`PersonaDialer::rebuild_usage_rollups`] pass did.
2963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2964pub struct UsageRollupsRebuilt {
2965    /// Conversation partitions replayed.
2966    pub conversations_scanned: u32,
2967    /// Distinct personas that now hold a usage rollup.
2968    pub personas_rebuilt: u32,
2969}
2970
2971/// A freshly minted link-ceremony challenge: the code an edge must deliver
2972/// privately to the requesting user, plus its absolute expiry.
2973#[derive(Debug, Clone, PartialEq, Eq)]
2974pub struct StartedLink {
2975    /// The single-use 6-digit code (a credential — never log or display it
2976    /// outside a private channel the user owns).
2977    pub code: String,
2978    /// Unix-ms after which the code is refused.
2979    pub expires_at_ms: u64,
2980    /// The persona the code is bound to.
2981    pub persona_id: String,
2982}
2983
2984impl From<polyc_proto::proto::polychrome::persona::v1::StartLinkReply> for StartedLink {
2985    fn from(reply: polyc_proto::proto::polychrome::persona::v1::StartLinkReply) -> Self {
2986        Self {
2987            code: reply.code,
2988            expires_at_ms: reply.expires_at_ms,
2989            persona_id: reply.persona_id,
2990        }
2991    }
2992}
2993
2994impl From<polyc_proto::proto::polychrome::persona::v1::AdminInviteReply> for StartedLink {
2995    fn from(reply: polyc_proto::proto::polychrome::persona::v1::AdminInviteReply) -> Self {
2996        Self {
2997            code: reply.code,
2998            expires_at_ms: reply.expires_at_ms,
2999            persona_id: reply.persona_id,
3000        }
3001    }
3002}
3003
3004/// A freshly minted deep-link token: the opaque value an edge embeds in a
3005/// platform URL for a no-typing ceremony, plus its absolute expiry.
3006#[derive(Debug, Clone, PartialEq, Eq)]
3007pub struct StartedDeepLink {
3008    /// The single-use, high-entropy token (a credential — only ever placed in
3009    /// a deep-link URL delivered to a private channel the user owns).
3010    pub token: String,
3011    /// Unix-ms after which the token is refused.
3012    pub expires_at_ms: u64,
3013    /// The persona the token is bound to.
3014    pub persona_id: String,
3015}
3016
3017impl From<polyc_proto::proto::polychrome::persona::v1::StartDeepLinkReply> for StartedDeepLink {
3018    fn from(reply: polyc_proto::proto::polychrome::persona::v1::StartDeepLinkReply) -> Self {
3019        Self {
3020            token: reply.token,
3021            expires_at_ms: reply.expires_at_ms,
3022            persona_id: reply.persona_id,
3023        }
3024    }
3025}
3026
3027/// The outcome of completing a link ceremony, in edge-renderable terms.
3028///
3029/// `InvalidCode` and `Expired` are deliberately fused into one variant: the
3030/// proto contract is that edges present them identically so a guesser cannot
3031/// learn whether a code was ever valid. The distinction stays in the control
3032/// plane's logs.
3033#[derive(Debug, Clone, PartialEq, Eq)]
3034pub enum LinkCeremony {
3035    /// The identity is now bound to the persona (directly or by merge).
3036    Linked {
3037        /// The surviving persona id.
3038        persona_id: String,
3039    },
3040    /// The identity already resolved to that persona; the code was consumed.
3041    AlreadyLinked {
3042        /// The persona id.
3043        persona_id: String,
3044    },
3045    /// No usable code (never minted, already consumed, or expired). Rendered
3046    /// identically to the user.
3047    InvalidOrExpired,
3048    /// The completing identity is locked out after repeated failures (or the
3049    /// deployment-wide backstop tripped). Rendered distinctly: "wait".
3050    Throttled,
3051    /// An unexpected/unspecified outcome — the edge should surface a generic
3052    /// failure rather than claim success.
3053    Failed,
3054    /// The completing channel already belongs to its own established
3055    /// persona; absorbing it would be an unguarded merge, so the ceremony
3056    /// refused instead. The code was not consumed — the reader can retry by
3057    /// starting the link from that established account instead.
3058    EstablishedPersona,
3059}
3060
3061impl LinkCeremony {
3062    /// The user-facing message for this outcome, shared by every edge.
3063    ///
3064    /// `InvalidCode` and `Expired` are fused into one variant upstream (so no
3065    /// edge can distinguish them — no oracle for a guesser); centralizing the
3066    /// rendering here keeps that security-relevant wording identical across
3067    /// surfaces. Channel-neutral phrasing; an edge that needs surface-specific
3068    /// text can still match the variant itself.
3069    #[must_use]
3070    pub const fn user_message(&self) -> &'static str {
3071        match self {
3072            Self::Linked { .. } => {
3073                "✅ Linked — this account now shares one Polychrome persona with your other channels."
3074            }
3075            Self::AlreadyLinked { .. } => {
3076                "✅ Already linked — this account was already on that persona."
3077            }
3078            Self::InvalidOrExpired => {
3079                "That link is invalid or expired. Start a fresh one from your other channel and try again."
3080            }
3081            Self::Throttled => "Too many attempts — wait a few minutes, then try again.",
3082            Self::Failed => "That link failed to complete. Start a fresh one and try again.",
3083            Self::EstablishedPersona => {
3084                "This account already has its own established persona. Start the link from this account instead, then complete it on the other channel."
3085            }
3086        }
3087    }
3088}
3089
3090/// What [`PersonaDialer::attest_verified_email`] did. Not rendered to a real
3091/// person — the caller is a first-party backend, not an edge — so unlike
3092/// [`LinkCeremony`] this carries no `user_message`.
3093#[derive(Debug, Clone, PartialEq, Eq)]
3094pub enum AttestedEmail {
3095    /// The identity is now bound to the resolved persona (directly, or by
3096    /// absorbing its prior PROVISIONAL persona).
3097    Linked {
3098        /// The surviving persona id.
3099        persona_id: String,
3100    },
3101    /// The identity already resolved to that persona; now marked verified.
3102    AlreadyLinked {
3103        /// The persona id.
3104        persona_id: String,
3105    },
3106    /// The identity resolves to its own already-established persona;
3107    /// absorbing it would be an unguarded merge, so this refused instead.
3108    EstablishedPersona,
3109    /// The asserted email is on the deployment's blocked-identifier list.
3110    Blocked,
3111    /// Binding this email would exceed the deployment's per-provider
3112    /// identity cardinality cap on the target persona.
3113    CapHalted,
3114    /// An unexpected/unspecified outcome.
3115    Failed,
3116}
3117
3118/// The user-facing message for an admin-invite dial failure, shared by every
3119/// edge.
3120///
3121/// Centralizes the wording so the Slack and Telegram invite handlers can't
3122/// drift on copy for the identical state (the CLAUDE.md "route through a
3123/// shared helper" rule). Permission-denied maps to [`ADMIN_ONLY_INVITE`]; a
3124/// surface where echoing a refusal is unsafe (a group chat, where it would
3125/// let a non-admin make the bot speak / probe who is an admin) can suppress
3126/// the message itself rather than call this.
3127#[must_use]
3128pub const fn invite_error_message(err: &DialError) -> &'static str {
3129    match err.code() {
3130        Some(ErrorCode::PermissionDenied) => ADMIN_ONLY_INVITE,
3131        Some(ErrorCode::ResourceExhausted) => {
3132            "That's a lot of invites in a row — wait a couple of minutes, then try again."
3133        }
3134        _ => "I couldn't create the invite right now — try again in a moment.",
3135    }
3136}
3137
3138/// The "only admins can invite" refusal copy, shared across edges.
3139pub const ADMIN_ONLY_INVITE: &str = "Only admins can send invites.";
3140
3141/// The opening line of the target-facing invite message, shared across
3142/// every edge so the greeting can't drift.
3143///
3144/// `#1628`, the CLAUDE.md "route through a shared helper" rule. Blank-safe:
3145/// with an inviter name it reads "Ada set you up with access", without one
3146/// it opens with a neutral "You're invited to Polychrome."
3147///
3148/// Each edge appends its OWN redemption instructions after this — the
3149/// mechanism genuinely differs per edge (Slack redeems via a `link <code>`
3150/// text command; Telegram via a `/start <code>` deep link), so that part
3151/// can't be shared without hardcoding one edge's UX into the others.
3152#[must_use]
3153pub fn invite_opener(inviter: &str) -> String {
3154    if inviter.trim().is_empty() {
3155        "🎟️ You're invited to Polychrome.".to_owned()
3156    } else {
3157        format!("🎟️ You're invited. {inviter} set you up with access.")
3158    }
3159}
3160
3161/// The codeless acknowledgement the admin sees once an invite is on its way.
3162/// Never the code, never the redemption mechanism — just confirms who and
3163/// when it expires, identically on every edge.
3164///
3165/// `target_display` is the edge's own rendering of the target (Slack:
3166/// `<@U123>`; Telegram: `@username` or a first name) — this helper owns only
3167/// the shared sentence shape, not per-edge mention syntax.
3168#[must_use]
3169pub fn invite_sent_ack(target_display: &str) -> String {
3170    format!("📬 Invite sent to {target_display} by DM. It expires in 60 minutes.")
3171}
3172
3173/// User-facing copy for a [`PersonaDialer::set_incognito`] failure (`#796`),
3174/// shared across edges so the toggle reads identically wherever it's
3175/// reachable from.
3176#[must_use]
3177pub const fn incognito_error_message(err: &DialError) -> &'static str {
3178    match err.code() {
3179        Some(ErrorCode::PermissionDenied) => {
3180            "I couldn't toggle incognito — that only works for a conversation you're part of."
3181        }
3182        _ => "I couldn't toggle incognito — try again.",
3183    }
3184}
3185
3186/// A human's decision on a pending `ask_question` question (`#1660`).
3187///
3188/// The question-pause SIBLING of [`ApprovalChoice`], not a reuse of it:
3189/// `ask_question` offers a fixed, model-proposed option set (or an explicit
3190/// decline), never an approve/deny/session/abort/defer decision.
3191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3192pub enum QuestionChoice {
3193    /// Pick the option at this index within the question's `options` array.
3194    SelectOption(u32),
3195    /// Explicitly decline to choose — "use your own judgment".
3196    Decline,
3197}
3198
3199/// Outcome of a [`QuestionDialer::respond`] call — the question-pause
3200/// SIBLING of [`ApprovalOutcome`].
3201#[derive(Debug, Clone, PartialEq, Eq)]
3202pub struct QuestionOutcome {
3203    /// `true` if a matching pending question existed and the answer was
3204    /// persisted; `false` is the idempotent no-op (already answered /
3205    /// unknown).
3206    pub persisted: bool,
3207    /// Lowercase-hex ed25519 signature over the canonical answer bytes.
3208    /// Empty if `persisted` is false.
3209    pub signature_hex: String,
3210    /// Lowercase-hex public key the signature verifies against. Empty if
3211    /// `persisted` is false.
3212    pub signed_by_hex: String,
3213}
3214
3215impl From<polyc_proto::proto::polychrome::question::v1::QuestionAnswerReply> for QuestionOutcome {
3216    fn from(reply: polyc_proto::proto::polychrome::question::v1::QuestionAnswerReply) -> Self {
3217        Self {
3218            persisted: reply.persisted,
3219            signature_hex: reply.signature_hex,
3220            signed_by_hex: reply.signed_by_hex,
3221        }
3222    }
3223}
3224
3225/// Reusable handle for the control plane's `QuestionService` (`#1660`).
3226///
3227/// The THIN `ask_question` pause/resume path — the question-pause SIBLING of
3228/// [`ApprovalDialer`], not a reuse of it: its own event kinds, its own
3229/// signed-answer canonical (`polyc_crypto::question`), so an approval
3230/// signature can never verify as a question answer and vice versa. Shares
3231/// the `AgentService` endpoint — all these services are served on one
3232/// Connect port — so it is built from the same address.
3233#[derive(Clone)]
3234pub struct QuestionDialer {
3235    client: Arc<QuestionServiceClient<HttpClient>>,
3236}
3237
3238impl QuestionDialer {
3239    /// Build a dialer pointed at `addr` (expects `http://host:port`),
3240    /// carrying NO `Authorization` header.
3241    ///
3242    /// `#1660` incident (2026-07-28): this used to be the only constructor,
3243    /// on the mistaken assumption that a `Respond` call's per-question
3244    /// `answer_token` was sufficient auth on its own. It authorizes THAT
3245    /// answer, but it is not what the control plane's `require_edge_bearer`
3246    /// layer checks — that layer guards every RPC on this listener
3247    /// regardless of the call's own payload, `QuestionService` included. An
3248    /// edge with real edge credentials MUST use [`Self::with_bearer`]
3249    /// instead, or every answer submission fails closed with 401. This
3250    /// constructor now exists only for the genuinely uncredentialed case
3251    /// (mirroring [`ApprovalDialer::new`]).
3252    ///
3253    /// # Errors
3254    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI.
3255    pub fn new(addr: &str) -> Result<Self, DialError> {
3256        Ok(Self {
3257            client: build_control_client(addr, None, QuestionServiceClient::new)?,
3258        })
3259    }
3260
3261    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
3262    ///
3263    /// Every call this dialer makes carries an `Authorization: Bearer
3264    /// <bearer>` header — required by the control plane's
3265    /// `require_edge_bearer` layer, which guards `QuestionService` like
3266    /// every other RPC on this listener (see [`Self::new`]'s doc for why
3267    /// this constructor exists). `respond`'s per-question `answer_token` is
3268    /// a SEPARATE, per-call authorization on top of this — bearer proves
3269    /// "this is a registered edge", `answer_token` proves "this is a valid
3270    /// answer to this specific question".
3271    ///
3272    /// # Errors
3273    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
3274    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
3275    /// header value.
3276    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
3277        Ok(Self {
3278            client: build_control_client(addr, Some(bearer), QuestionServiceClient::new)?,
3279        })
3280    }
3281
3282    /// Submit a human's answer for a pending question. The answer is
3283    /// UNSIGNED — the control plane resolves and signs it server-side (THIN
3284    /// path, mirrors [`ApprovalDialer::respond`]) and returns the signature.
3285    /// Idempotent: answering an already-decided or unknown occurrence returns
3286    /// `persisted: false`.
3287    ///
3288    /// `turn_id` is the occurrence this answer names, carried unmodified off
3289    /// the same card the `answer_token` came from (`#2523`). The control plane
3290    /// refuses a `Respond` that names no occurrence: a provider re-mints a
3291    /// tool-call id across turns, so `(call_id, index)` alone would let one
3292    /// person's decision land on a different question.
3293    ///
3294    /// `answer_token` is the short-lived signed capability carried unmodified
3295    /// off the [`TurnEvent::QuestionPending`] event or [`PendingQuestionPrompt`]
3296    /// entry this decision answers — required: the control plane rejects a
3297    /// `Respond` whose token is missing, expired, or bound to a different
3298    /// question or conversation.
3299    ///
3300    /// `responder` is the identity of the human answering — the durable
3301    /// `answered_by` fact. An edge that cannot supply caller identity passes
3302    /// `None`. Unsigned, unlike [`ApprovalDialer::respond`]'s: answering a
3303    /// question authorizes nothing, so no gate reads this.
3304    ///
3305    /// # Errors
3306    /// Returns [`DialError::Connect`] for any transport/encoding error
3307    /// (including the control plane's rejection of an invalid
3308    /// `answer_token`).
3309    #[allow(clippy::too_many_arguments)] // each is a distinct field of the answer
3310    pub async fn respond(
3311        &self,
3312        turn_id: &str,
3313        call_id: &str,
3314        index: u32,
3315        choice: QuestionChoice,
3316        conversation_id: &str,
3317        answer_token: &str,
3318        responder: Option<ExternalIdentity>,
3319    ) -> Result<QuestionOutcome, DialError> {
3320        let answer = match choice {
3321            QuestionChoice::SelectOption(index) => {
3322                WireAnswer::SelectOption(Box::new(WireSelectOption {
3323                    index,
3324                    __buffa_unknown_fields: buffa::UnknownFields::default(),
3325                }))
3326            }
3327            QuestionChoice::Decline => WireAnswer::Decline(Box::new(WireDecline {
3328                __buffa_unknown_fields: buffa::UnknownFields::default(),
3329            })),
3330        };
3331        let request = QuestionAnswerRequest {
3332            turn_id: turn_id.to_owned(),
3333            call_id: call_id.to_owned(),
3334            index,
3335            conversation_id: conversation_id.to_owned(),
3336            answer: Some(answer),
3337            answer_token: answer_token.to_owned(),
3338            responder: responder.map_or_else(buffa::MessageField::none, buffa::MessageField::some),
3339            ..Default::default()
3340        };
3341        let reply = self
3342            .client
3343            .respond_with_options(request, traced_options())
3344            .await?
3345            .into_owned();
3346        Ok(reply.into())
3347    }
3348
3349    /// List the conversation's outstanding questions (a `question_request`
3350    /// with no later `question_response`). An edge calls this to recover
3351    /// occurrences it must prompt on after losing the streamed
3352    /// [`TurnEvent::QuestionPending`] event — mirrors
3353    /// [`ApprovalDialer::list_pending`]. Read-only and idempotent.
3354    ///
3355    /// # Errors
3356    /// Returns [`DialError::Connect`] for any transport/encoding error.
3357    pub async fn list_pending(
3358        &self,
3359        conversation_id: &str,
3360    ) -> Result<Vec<PendingQuestionPrompt>, DialError> {
3361        let mut pending = Vec::new();
3362        let mut page_token = String::new();
3363        loop {
3364            let request = ListPendingQuestionsRequest {
3365                conversation_id: conversation_id.to_owned(),
3366                page_token: page_token.clone(),
3367                ..Default::default()
3368            };
3369            let reply = self
3370                .client
3371                .list_pending_with_options(request, traced_options())
3372                .await?
3373                .into_owned();
3374            pending.extend(reply.pending.into_iter().map(PendingQuestionPrompt::from));
3375            if reply.next_page_token.is_empty() {
3376                break;
3377            }
3378            page_token = reply.next_page_token;
3379        }
3380        Ok(pending)
3381    }
3382}
3383
3384/// Reusable handle for the control plane's `PersonaService`.
3385///
3386/// The verified identity-linking ceremonies. Shares the `AgentService`
3387/// endpoint — all three services are served on one internal Connect port —
3388/// so it is built from the same address.
3389#[derive(Clone)]
3390pub struct PersonaDialer {
3391    client: Arc<PersonaServiceClient<HttpClient>>,
3392}
3393
3394impl PersonaDialer {
3395    /// Build a dialer pointed at `addr` (expects `http://host:port`).
3396    ///
3397    /// # Errors
3398    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI.
3399    pub fn new(addr: &str) -> Result<Self, DialError> {
3400        Ok(Self {
3401            client: build_control_client(addr, None, PersonaServiceClient::new)?,
3402        })
3403    }
3404
3405    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
3406    ///
3407    /// Every call this dialer makes carries an `Authorization: Bearer
3408    /// <bearer>` header. `PersonaService` doesn't send `AgentStart`, so no
3409    /// signed [`AssertedAttribution`] envelope rides these calls — bearer-only.
3410    ///
3411    /// # Errors
3412    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
3413    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
3414    /// header value.
3415    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
3416        Ok(Self {
3417            client: build_control_client(addr, Some(bearer), PersonaServiceClient::new)?,
3418        })
3419    }
3420
3421    /// Build a dialer pointed at `addr`, presenting `bearer` as the `#803`
3422    /// admin service-credential (`Authorization: Bearer pc_<id>_<secret>`,
3423    /// a current record in State's credential authority carrying admin
3424    /// access) every `require_admin`-gated `PersonaService` RPC checks before
3425    /// it will even look at the body-supplied actor. [`Self::new`]'s dialer never presents this header
3426    /// — use `new_admin` (not `new`) for [`Self::rebuild_usage_rollups`] and
3427    /// any future RPC gated the same way.
3428    ///
3429    /// # Errors
3430    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI,
3431    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
3432    /// header value, or [`DialError::Tls`] if an `https` endpoint's TLS setup
3433    /// fails.
3434    pub fn new_admin(addr: &str, bearer: &str) -> Result<Self, DialError> {
3435        Ok(Self {
3436            client: build_control_client(addr, Some(bearer), PersonaServiceClient::new)?,
3437        })
3438    }
3439
3440    /// Admin-gated maintenance (`#457`): recompute every persona's usage
3441    /// rollup from a full eventlog replay and overwrite the maintained
3442    /// counters wholesale — the repair path for the crash window a
3443    /// best-effort post-commit rollup write leaves open. Build this dialer
3444    /// with [`Self::new_admin`], not [`Self::new`].
3445    ///
3446    /// # Errors
3447    /// Returns [`DialError::Connect`] for any transport/encoding error,
3448    /// including a `permission_denied` refusal (missing/invalid bearer, or a
3449    /// non-admin actor).
3450    pub async fn rebuild_usage_rollups(
3451        &self,
3452        actor: ExternalIdentity,
3453    ) -> Result<UsageRollupsRebuilt, DialError> {
3454        let request = RebuildUsageRollupsRequest {
3455            actor: buffa::MessageField::some(actor),
3456            ..Default::default()
3457        };
3458        let reply = self
3459            .client
3460            .rebuild_usage_rollups_with_options(request, traced_options())
3461            .await?
3462            .into_owned();
3463        Ok(UsageRollupsRebuilt {
3464            conversations_scanned: reply.conversations_scanned,
3465            personas_rebuilt: reply.personas_rebuilt,
3466        })
3467    }
3468
3469    /// Mint a single-use link-ceremony code bound to `identity`'s persona
3470    /// (provisioning one on first contact). The returned [`StartedLink::code`]
3471    /// is a credential the caller must deliver only to a private channel the
3472    /// user owns.
3473    ///
3474    /// # Errors
3475    /// Returns [`DialError::Connect`] for any transport/encoding error.
3476    pub async fn start_link(&self, identity: ExternalIdentity) -> Result<StartedLink, DialError> {
3477        let request = StartLinkRequest {
3478            identity: buffa::MessageField::some(identity),
3479            ..Default::default()
3480        };
3481        let reply = self
3482            .client
3483            .start_link_with_options(request, traced_options())
3484            .await?
3485            .into_owned();
3486        Ok(reply.into())
3487    }
3488
3489    /// Mint a high-entropy deep-link token bound to `identity`'s persona, for
3490    /// a no-typing ceremony (the caller embeds [`StartedDeepLink::token`] in a
3491    /// platform URL like `https://t.me/<bot>?start=<token>`). The token is a
3492    /// credential — deliver the URL only to a private channel the user owns.
3493    /// Completed via [`Self::complete_link`] from the target platform.
3494    ///
3495    /// # Errors
3496    /// Returns [`DialError::Connect`] for any transport/encoding error.
3497    pub async fn start_deeplink(
3498        &self,
3499        identity: ExternalIdentity,
3500    ) -> Result<StartedDeepLink, DialError> {
3501        let request = StartDeepLinkRequest {
3502            identity: buffa::MessageField::some(identity),
3503            ..Default::default()
3504        };
3505        let reply = self
3506            .client
3507            .start_deep_link_with_options(request, traced_options())
3508            .await?
3509            .into_owned();
3510        Ok(reply.into())
3511    }
3512
3513    /// Admin-gated: mint an invite code for a TARGET identity distinct from
3514    /// the acting admin. The code binds to the target — only the target can
3515    /// redeem it — and is a credential: deliver it only to a private channel
3516    /// the TARGET owns, never back through the admin's shared surfaces.
3517    ///
3518    /// # Errors
3519    /// Returns [`DialError::Connect`] for any transport/encoding error; a
3520    /// non-admin actor surfaces as `permission_denied`.
3521    pub async fn admin_invite(
3522        &self,
3523        actor: ExternalIdentity,
3524        target: ExternalIdentity,
3525    ) -> Result<StartedLink, DialError> {
3526        let request = AdminInviteRequest {
3527            actor: buffa::MessageField::some(actor),
3528            target: buffa::MessageField::some(target),
3529            ..Default::default()
3530        };
3531        let reply = self
3532            .client
3533            .admin_invite_with_options(request, traced_options())
3534            .await?
3535            .into_owned();
3536        Ok(reply.into())
3537    }
3538
3539    /// Toggle incognito for a conversation (`#796`): suppresses memory
3540    /// extraction from here on when `on`, clears the suppression when not.
3541    /// The control plane authorizes `actor` — an admin may toggle any
3542    /// conversation, anyone else only one they are a caller or participant
3543    /// of — so a non-admin's own conversation is reachable through this same
3544    /// call; an unrelated conversation surfaces as `permission_denied`.
3545    ///
3546    /// # Errors
3547    /// Returns [`DialError::Connect`] for any transport/encoding error,
3548    /// including a `permission_denied` refusal.
3549    pub async fn set_incognito(
3550        &self,
3551        actor: ExternalIdentity,
3552        conversation_id: &str,
3553        on: bool,
3554    ) -> Result<bool, DialError> {
3555        let request = SetIncognitoRequest {
3556            actor: buffa::MessageField::some(actor),
3557            conversation_id: conversation_id.to_owned(),
3558            on,
3559            ..Default::default()
3560        };
3561        let reply = self
3562            .client
3563            .set_incognito_with_options(request, traced_options())
3564            .await?
3565            .into_owned();
3566        Ok(reply.on)
3567    }
3568
3569    /// Deterministic workspace-email auto-link: bind `identity` to the
3570    /// persona already holding a ceremony-verified email matching
3571    /// `asserted_email`. The email must come from the PLATFORM API (e.g.
3572    /// Slack `users.info`) — never from user-typed text. Returns whether a
3573    /// link happened so the edge can notify the user ("if this wasn't
3574    /// you…"); halting outcomes come back as `Ok(None)`.
3575    ///
3576    /// # Errors
3577    /// Returns [`DialError::Connect`] for any transport/encoding error.
3578    pub async fn auto_link(
3579        &self,
3580        identity: ExternalIdentity,
3581        asserted_email: &str,
3582        basis: &str,
3583    ) -> Result<Option<String>, DialError> {
3584        let request = AutoLinkRequest {
3585            identity: buffa::MessageField::some(identity),
3586            asserted_email: asserted_email.to_owned(),
3587            basis: basis.to_owned(),
3588            ..Default::default()
3589        };
3590        let reply = self
3591            .client
3592            .auto_link_with_options(request, traced_options())
3593            .await?
3594            .into_owned();
3595        Ok(match reply.outcome.as_known() {
3596            Some(AutoLinkOutcome::Linked) => Some(reply.persona_id),
3597            _ => None,
3598        })
3599    }
3600
3601    /// First-party backend attestation (docs/reference/personas.md §2): assert
3602    /// that `identity`'s email is already verified by the calling backend's
3603    /// own signup flow — no code, no magic-link round trip. Requires a
3604    /// service credential carrying `Capabilities::attest_email`; the caller
3605    /// is authenticated as itself, never as `identity`. Resolves-or-
3606    /// provisions `identity`: a genuinely first-time identity (no prior
3607    /// agent contact) still gets a fresh persona in this one call — the
3608    /// caller may attest at raw account creation or lazily on/after first
3609    /// contact, whichever fits.
3610    ///
3611    /// # Errors
3612    /// Returns [`DialError::Connect`] for any transport/encoding error,
3613    /// including `permission_denied` when the presented credential lacks
3614    /// `attest_email`.
3615    pub async fn attest_verified_email(
3616        &self,
3617        identity: ExternalIdentity,
3618        verified_email: &str,
3619    ) -> Result<AttestedEmail, DialError> {
3620        let request = AttestVerifiedEmailRequest {
3621            identity: buffa::MessageField::some(identity),
3622            verified_email: verified_email.to_owned(),
3623            ..Default::default()
3624        };
3625        let reply = self
3626            .client
3627            .attest_verified_email_with_options(request, traced_options())
3628            .await?
3629            .into_owned();
3630        Ok(match reply.outcome.as_known() {
3631            Some(AttestVerifiedEmailOutcome::Linked) => AttestedEmail::Linked {
3632                persona_id: reply.persona_id,
3633            },
3634            Some(AttestVerifiedEmailOutcome::AlreadyLinked) => AttestedEmail::AlreadyLinked {
3635                persona_id: reply.persona_id,
3636            },
3637            Some(AttestVerifiedEmailOutcome::EstablishedPersona) => {
3638                AttestedEmail::EstablishedPersona
3639            }
3640            Some(AttestVerifiedEmailOutcome::Blocked) => AttestedEmail::Blocked,
3641            Some(AttestVerifiedEmailOutcome::CapHalted) => AttestedEmail::CapHalted,
3642            Some(AttestVerifiedEmailOutcome::Unspecified) | None => AttestedEmail::Failed,
3643        })
3644    }
3645
3646    /// Consume `code` from the channel being claimed, binding `identity` to
3647    /// the minting persona (merging `identity`'s prior persona if it had one).
3648    ///
3649    /// # Errors
3650    /// Returns [`DialError::Connect`] for any transport/encoding error.
3651    pub async fn complete_link(
3652        &self,
3653        code: &str,
3654        identity: ExternalIdentity,
3655    ) -> Result<LinkCeremony, DialError> {
3656        let request = CompleteLinkRequest {
3657            code: code.to_owned(),
3658            identity: buffa::MessageField::some(identity),
3659            ..Default::default()
3660        };
3661        let reply = self
3662            .client
3663            .complete_link_with_options(request, traced_options())
3664            .await?
3665            .into_owned();
3666        Ok(match reply.outcome.as_known() {
3667            Some(LinkOutcome::Linked) => LinkCeremony::Linked {
3668                persona_id: reply.persona_id,
3669            },
3670            Some(LinkOutcome::AlreadyLinked) => LinkCeremony::AlreadyLinked {
3671                persona_id: reply.persona_id,
3672            },
3673            // Fused on purpose — see `LinkCeremony::InvalidOrExpired`.
3674            Some(LinkOutcome::InvalidCode | LinkOutcome::Expired) => LinkCeremony::InvalidOrExpired,
3675            Some(LinkOutcome::Throttled) => LinkCeremony::Throttled,
3676            Some(LinkOutcome::EstablishedPersona) => LinkCeremony::EstablishedPersona,
3677            Some(LinkOutcome::Unspecified) | None => LinkCeremony::Failed,
3678        })
3679    }
3680
3681    /// Read-only: the profile the `identity` resolves to, as an edge-friendly
3682    /// [`PersonaView`]. Returns an all-empty view for an identity not yet in
3683    /// the directory; **never provisions** a persona.
3684    ///
3685    /// # Errors
3686    /// Returns [`DialError::Connect`] for any transport/encoding error.
3687    pub async fn describe(&self, identity: ExternalIdentity) -> Result<PersonaView, DialError> {
3688        let request = DescribeRequest {
3689            identity: buffa::MessageField::some(identity),
3690            ..Default::default()
3691        };
3692        let reply = self
3693            .client
3694            .describe_with_options(request, traced_options())
3695            .await?
3696            .into_owned();
3697        // Unknown identity → empty profile → an all-default view (the
3698        // not-yet-claimed dashboard state).
3699        let Some(profile) = reply.profile.into_option() else {
3700            return Ok(PersonaView {
3701                persona_id: reply.persona_id,
3702                ..Default::default()
3703            });
3704        };
3705        Ok(PersonaView {
3706            persona_id: reply.persona_id,
3707            status: profile.status,
3708            identities: profile
3709                .identities
3710                .into_iter()
3711                .map(LinkedIdentity::from)
3712                .collect(),
3713        })
3714    }
3715}
3716
3717/// One external identity linked to a persona, as the dashboard renders it
3718/// (provider · display name · id).
3719#[derive(Debug, Clone, PartialEq, Eq)]
3720pub struct LinkedIdentity {
3721    /// Edge namespace ("slack", "telegram", …).
3722    pub provider: String,
3723    /// The provider-native stable id.
3724    pub external_id: String,
3725    /// Display name as last observed (presentation only).
3726    pub display_name: String,
3727}
3728
3729/// Deliberate subset projection: the dashboard doesn't render `scope`, the
3730/// one field the wire `ExternalIdentity` has beyond these three.
3731impl From<ExternalIdentity> for LinkedIdentity {
3732    fn from(id: ExternalIdentity) -> Self {
3733        Self {
3734            provider: id.provider,
3735            external_id: id.external_id,
3736            display_name: id.display_name,
3737        }
3738    }
3739}
3740
3741/// An edge-friendly read of a persona, for surfaces like the App Home
3742/// dashboard. Every field is empty when the queried identity is not in the
3743/// directory (a not-yet-claimed user).
3744#[derive(Debug, Clone, PartialEq, Eq, Default)]
3745pub struct PersonaView {
3746    /// The persona id; empty when the identity is unknown.
3747    pub persona_id: String,
3748    /// "provisional" | "linked" | "merged"; empty when unknown.
3749    pub status: String,
3750    /// Every identity currently linked to the persona.
3751    pub identities: Vec<LinkedIdentity>,
3752}
3753
3754/// A turn-input message, re-exported so callers can build attributed
3755/// multi-party input for [`AgentDialer::run_turn_streaming_messages`] without
3756/// depending on `polyc-proto`.
3757pub use polyc_proto::proto::polychrome::agent::v1::Message as TurnMessage;
3758/// The attributed transcript line type the participation gate consumes.
3759/// Re-exported so callers build requests without importing `polyc-proto`.
3760pub use polyc_proto::proto::polychrome::agent::v1::ParticipantMessage as GateMessage;
3761/// A settled inbound payment receipt, re-exported so the public-edge layer can
3762/// thread it into [`AgentDialer::run_turn_streaming_messages_with`]
3763/// without depending on `polyc-proto` directly.
3764pub use polyc_proto::proto::polychrome::agent::v1::PaymentReceipt;
3765
3766/// The external identity of a human observed at an edge (re-exported wire
3767/// type, shared with the persona store records): the per-edge `EdgeAdapter`
3768/// mapping produces these and the control plane resolves them to durable
3769/// personas.
3770pub use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
3771
3772/// The Connect error-code enum, re-exported so edges can branch on
3773/// [`DialError::code`] (e.g. render `permission_denied` differently from a
3774/// transient failure) without depending on `connectrpc` directly.
3775pub use connectrpc::ErrorCode;
3776
3777/// The Connect transport error [`DialError::Connect`] wraps, re-exported for
3778/// the same reason [`ErrorCode`] is: an edge that maps a dial failure onto its
3779/// own surface's error taxonomy needs to name the type it is mapping from.
3780pub use connectrpc::ConnectError;
3781
3782/// Caller attribution for one turn.
3783///
3784/// Carries the identity whose message triggered the turn (`caller`) and any
3785/// other humans whose messages entered the turn's input (`participants`).
3786/// Edges without caller identity pass the default.
3787#[derive(Debug, Clone, Default)]
3788pub struct Attribution {
3789    /// The identity whose message triggered the turn.
3790    pub caller: Option<ExternalIdentity>,
3791    /// Other humans whose messages entered this turn's input.
3792    pub participants: Vec<ExternalIdentity>,
3793}
3794
3795/// Build an attributed user-role turn message rendered as `"<speaker>: text"`,
3796/// so the model sees who said what in a multi-party thread.
3797#[must_use]
3798pub fn attributed_message(speaker: &str, text: &str) -> TurnMessage {
3799    text_message("user", &format!("{speaker}: {text}"))
3800}
3801
3802/// Build a plain user-role turn message (no speaker attribution) — used for
3803/// 1:1 DMs where there is only one human in the conversation.
3804#[must_use]
3805pub fn user_message(text: &str) -> TurnMessage {
3806    text_message("user", text)
3807}
3808
3809/// Build the documented **readable** namespaced conversation id,
3810/// `"{namespace}:{native_id}"`.
3811///
3812/// Every edge derives [`AgentRequest::conversation_id`] from its native unit
3813/// (a thread, a ticket, an issue, a session). The convention is a namespace
3814/// prefix that names the edge family, followed by the edge's own stable handle
3815/// for the conversation — e.g. `mail:{message-id}`, `web:{session-uuid}`,
3816/// `mcp:{caller-id}`. The id is opaque to the orchestration core (it is the
3817/// event-log partition key, `conv-{id}`); only the *format* is a shared
3818/// convention so edges stay consistent and ids are greppable.
3819///
3820/// Edges whose native coordinate is unwieldy or sensitive — many chat
3821/// platforms — should instead use [`hashed_conversation_id`], which collapses
3822/// the coordinate into a fixed-length opaque `UUIDv5` under a pinned namespace.
3823#[must_use]
3824pub fn namespaced_id(namespace: &str, native_id: &str) -> String {
3825    format!("{namespace}:{native_id}")
3826}
3827
3828/// Derive a stable, fixed-length conversation id by hashing `parts` into a
3829/// `UUIDv5` under a pinned `namespace`.
3830///
3831/// This is the "opaque hash" namespacing policy: a deterministic,
3832/// stateless-across-restarts id (no `native → id` table to keep) that is
3833/// globally unique enough to key the event-log partition. `parts` are joined
3834/// with `':'` before hashing, so a caller passing `["T1", "C1", "169…"]`
3835/// hashes exactly `"T1:C1:169…"`.
3836///
3837/// The pinned `namespace` UUID **must never change** for a given edge — every
3838/// existing conversation id for that edge depends on it. `polychrome-slack`
3839/// uses this policy (its `UUIDv5` over `(team, channel, thread_ts)`); new edges
3840/// pick their own frozen namespace.
3841#[must_use]
3842pub fn hashed_conversation_id(namespace: uuid::Uuid, parts: &[&str]) -> String {
3843    let joined = parts.join(":");
3844    uuid::Uuid::new_v5(&namespace, joined.as_bytes())
3845        .hyphenated()
3846        .to_string()
3847}
3848
3849/// Collision-free variant of [`hashed_conversation_id`] for edges whose native
3850/// parts can themselves contain `':'` (e.g. an email `Message-ID`, a URL, a
3851/// path).
3852///
3853/// [`hashed_conversation_id`] joins parts with a bare `':'`, so `["a:b", "c"]`
3854/// and `["a", "b:c"]` both hash `"a:b:c"` and collide. This helper instead
3855/// **length-prefixes** each part (`"{len}:{part}"`), which is unambiguous
3856/// regardless of any `':'` inside a part — the length says exactly how many
3857/// bytes the part occupies, so distinct part boundaries always produce distinct
3858/// hash inputs. Use it for any edge whose coordinate fields are not guaranteed
3859/// `':'`-free.
3860///
3861/// Distinct from [`hashed_conversation_id`] precisely because the framing
3862/// differs; the two do not agree for the same `parts`.
3863#[must_use]
3864pub fn framed_conversation_id(namespace: uuid::Uuid, parts: &[&str]) -> String {
3865    let mut framed = String::new();
3866    for p in parts {
3867        framed.push_str(&p.len().to_string());
3868        framed.push(':');
3869        framed.push_str(p);
3870    }
3871    uuid::Uuid::new_v5(&namespace, framed.as_bytes())
3872        .hyphenated()
3873        .to_string()
3874}
3875
3876/// Every field named explicitly — no `..Default::default()` spread — so a
3877/// field added to `WireIngressDirective` without updating this impl fails to
3878/// compile instead of silently not riding the wire. `IngressDirective` is a
3879/// fail-closed policy envelope (`#68`): a dropped `required_approver` or
3880/// `budget_cap` here is a fail-open bug, not just data loss.
3881impl From<IngressDirective> for WireIngressDirective {
3882    fn from(directive: IngressDirective) -> Self {
3883        Self {
3884            budget_cap: directive.budget_cap.unwrap_or_default(),
3885            priority: directive.priority.map_or_else(
3886                || buffa::EnumValue::from(Priority::PRIORITY_UNSPECIFIED),
3887                buffa::EnumValue::from,
3888            ),
3889            required_approver: directive
3890                .required_approver
3891                .map_or_else(buffa::MessageField::none, buffa::MessageField::some),
3892            __buffa_unknown_fields: buffa::UnknownFields::default(),
3893        }
3894    }
3895}
3896
3897/// Convert the SDK-facing [`IngressDirective`] into the wire
3898/// `polychrome.agent.v1.IngressDirective`, `None` for an empty directive
3899/// (`#68`) — so an edge that sets no policy sends the field unset, matching
3900/// [`IngressDirective::is_empty`]'s "byte-for-byte unaffected" contract.
3901fn wire_ingress_directive(
3902    directive: IngressDirective,
3903) -> buffa::MessageField<WireIngressDirective> {
3904    if directive.is_empty() {
3905        return buffa::MessageField::none();
3906    }
3907    buffa::MessageField::some(directive.into())
3908}
3909
3910/// Canonical bytes a turn's messages hash to for
3911/// [`AssertedAttribution::content_hash`].
3912///
3913/// Each [`Message`]'s buffa encoding, concatenated in order. The control
3914/// plane recomputes this same encoding over the messages it decodes off
3915/// `AgentStart.messages` (before its own smoke-test placeholder fallback,
3916/// which the signed envelope never covers) to check the two agree — see
3917/// `verify_ingest_envelope` in `crates/control-plane/src/grpc/mod.rs`.
3918/// Exported so both sides call one function instead of maintaining
3919/// independent encodings that could drift.
3920#[must_use]
3921pub fn encode_messages_for_content_hash(messages: &[Message]) -> Vec<u8> {
3922    use buffa::Message as _;
3923    let mut bytes = Vec::new();
3924    for m in messages {
3925        bytes.extend_from_slice(&m.encode_to_vec());
3926    }
3927    bytes
3928}
3929
3930/// Canonical bytes the participation gate's envelope binds to.
3931///
3932/// Covers the judged transcript plus the two prompt inputs that change the
3933/// verdict: the bot's own name and the surface wording. Each field is
3934/// length-prefixed, so no boundary shift can move bytes between fields and
3935/// leave the hash unchanged.
3936///
3937/// This is deliberately NOT the turn encoder: a turn binds
3938/// `AgentStart.messages`. Two encodings mean a captured turn envelope cannot
3939/// authorize an evaluation, or the reverse.
3940#[must_use]
3941pub fn encode_transcript_for_content_hash(
3942    transcript: &[ParticipantMessage],
3943    bot_name: &str,
3944    surface: &str,
3945) -> Vec<u8> {
3946    use buffa::Message as _;
3947    let mut bytes = Vec::new();
3948    for field in [bot_name.as_bytes(), surface.as_bytes()] {
3949        bytes.extend_from_slice(&(field.len() as u64).to_be_bytes());
3950        bytes.extend_from_slice(field);
3951    }
3952    for message in transcript {
3953        let encoded = message.encode_to_vec();
3954        bytes.extend_from_slice(&(encoded.len() as u64).to_be_bytes());
3955        bytes.extend_from_slice(&encoded);
3956    }
3957    bytes
3958}
3959
3960/// Builds the signed envelope for one participation evaluation.
3961///
3962/// It mirrors [`build_asserted_attribution`] with two deliberate differences:
3963/// `exec_id` is empty because no turn exists, and `content_hash` covers the
3964/// transcript rather than a turn's messages.
3965fn build_classify_attribution(
3966    creds: &EdgeCredentials,
3967    conversation_id: &str,
3968    source_identity: &IngressIdentity,
3969    namespace: &ClaimedNamespace,
3970    transcript: &[ParticipantMessage],
3971    bot_name: &str,
3972    surface: &str,
3973) -> AssertedAttribution {
3974    let content_hash = polyc_crypto::edge_identity::content_hash_hex(
3975        &encode_transcript_for_content_hash(transcript, bot_name, surface),
3976    );
3977    let mut envelope = AssertedAttribution {
3978        edge_id: creds.edge_id().to_owned(),
3979        conversation_id: conversation_id.to_owned(),
3980        nonce: uuid::Uuid::new_v4().to_string(),
3981        issued_unix_ms: issued_unix_ms(),
3982        caller: buffa::MessageField::none(),
3983        participants: Vec::new(),
3984        signature_hex: String::new(),
3985        exec_id: String::new(),
3986        content_hash,
3987        source_identity: buffa::MessageField::some(source_identity.to_wire()),
3988        // #1691: the gate claims the same tenancy namespace a turn does, and
3989        // the shared verifier refuses an envelope that names none.
3990        namespace: namespace.as_str().to_owned(),
3991        ..Default::default()
3992    };
3993    creds.sign_assertion(&mut envelope);
3994    envelope
3995}
3996
3997/// Milliseconds since the Unix epoch, for [`AssertedAttribution::issued_unix_ms`].
3998/// A clock that reads before the epoch (never in practice) maps to `0` rather
3999/// than panicking — a turn-signing helper must not crash the dial.
4000fn issued_unix_ms() -> i64 {
4001    let millis = std::time::SystemTime::now()
4002        .duration_since(std::time::UNIX_EPOCH)
4003        .map_or(0, |d| d.as_millis());
4004    i64::try_from(millis).unwrap_or(i64::MAX)
4005}
4006
4007/// Build the signed [`AssertedAttribution`] envelope for one turn: `creds`'
4008/// edge id, this turn's `conversation_id` and `exec_id`, a fresh per-call
4009/// nonce/timestamp, the `caller`/`participants` this turn asserts — the
4010/// envelope is their SOLE carrier, since `AgentStart`'s loose fields 5/6 are
4011/// reserved and gone (see `AgentStart.asserted_attribution`'s doc) — and
4012/// stable `source_identity`, and `content_hash` binding the envelope to `messages` (this turn's
4013/// `AgentStart.messages`) via [`encode_messages_for_content_hash`] +
4014/// [`polyc_crypto::edge_identity::content_hash_hex`].
4015///
4016/// This is the one implementation of the envelope contract. Other crates'
4017/// tests reach it through the `test-util`-gated [`test_util`] module rather
4018/// than re-deriving it, so a change here cannot silently diverge from what
4019/// those tests assert the control plane accepts.
4020fn build_asserted_attribution(
4021    creds: &EdgeCredentials,
4022    conversation_id: &str,
4023    exec_id: &str,
4024    source_identity: &IngressIdentity,
4025    namespace: &ClaimedNamespace,
4026    messages: &[Message],
4027    attribution: &Attribution,
4028) -> AssertedAttribution {
4029    let content_hash =
4030        polyc_crypto::edge_identity::content_hash_hex(&encode_messages_for_content_hash(messages));
4031    let mut envelope = AssertedAttribution {
4032        edge_id: creds.edge_id().to_owned(),
4033        conversation_id: conversation_id.to_owned(),
4034        nonce: uuid::Uuid::new_v4().to_string(),
4035        issued_unix_ms: issued_unix_ms(),
4036        caller: attribution
4037            .caller
4038            .clone()
4039            .map_or_else(buffa::MessageField::none, buffa::MessageField::some),
4040        participants: attribution.participants.clone(),
4041        signature_hex: String::new(),
4042        exec_id: exec_id.to_owned(),
4043        content_hash,
4044        source_identity: buffa::MessageField::some(source_identity.to_wire()),
4045        namespace: namespace.as_str().to_owned(),
4046        ..Default::default()
4047    };
4048    creds.sign_assertion(&mut envelope);
4049    envelope
4050}
4051
4052/// Builds the single signed [`AgentRequest`] sent to durable receive.
4053///
4054/// Shared by every buffered and streaming entry point so none can omit the
4055/// stable source identity. A credential-free dial fails closed instead of
4056/// placing that identity outside the signed assertion.
4057fn build_request(
4058    ingress: TurnIngress,
4059    credentials: Option<&EdgeCredentials>,
4060) -> Result<AgentRequest, DialError> {
4061    let credentials = credentials.ok_or(DialError::MissingIngressCredentials)?;
4062    let asserted_attribution = build_asserted_attribution(
4063        credentials,
4064        &ingress.conversation_id,
4065        &ingress.exec_id,
4066        &ingress.source_identity,
4067        &ingress.namespace,
4068        &ingress.messages,
4069        &ingress.attribution,
4070    );
4071    Ok(AgentRequest {
4072        conversation_id: ingress.conversation_id,
4073        exec_id: ingress.exec_id,
4074        start: buffa::MessageField::some(AgentStart {
4075            agent_id: String::new(),
4076            agent_config: Vec::new(),
4077            messages: ingress.messages,
4078            payment_receipt: ingress
4079                .payment_receipt
4080                .map_or_else(buffa::MessageField::none, buffa::MessageField::some),
4081            // #1514 review: the loose `caller`/`participants` fields were
4082            // removed from the wire (`reserved 5, 6` in `AgentStart`) — every
4083            // caller/participant now rides ONLY inside the signed
4084            // `asserted_attribution` envelope built below.
4085            // #843: a routine fire declares ephemeral history so each firing
4086            // starts the model on an empty transcript; every other edge leaves
4087            // this false (persistent).
4088            ephemeral_history: ingress.ephemeral_history,
4089            ingress_directive: wire_ingress_directive(ingress.ingress_directive),
4090            // #1103: the scheduled tick that produced this turn, when a routine's
4091            // cron binding fired it. Empty for every other caller — a chat turn
4092            // or a manual trigger send — so the field rides the wire unset and
4093            // never dedups.
4094            occurrence: ingress.occurrence,
4095            asserted_attribution: buffa::MessageField::some(asserted_attribution),
4096            ..Default::default()
4097        }),
4098        ..Default::default()
4099    })
4100}
4101
4102fn validate_ingress_receipt(
4103    receipt: &WireIngressReceipt,
4104    expected_source: &WireIngressSourceIdentity,
4105    expected_conversation: &str,
4106) -> Result<(), DialError> {
4107    let Some(source) = receipt.source_identity.as_option() else {
4108        return Err(DialError::InvalidIngressReceipt(
4109            "source identity is absent",
4110        ));
4111    };
4112    if source != expected_source {
4113        return Err(DialError::InvalidIngressReceipt(
4114            "source identity does not match the signed request",
4115        ));
4116    }
4117    if receipt.receipt.is_empty() {
4118        return Err(DialError::InvalidIngressReceipt("State receipt is empty"));
4119    }
4120    if receipt.dispatch_id.is_empty() {
4121        return Err(DialError::InvalidIngressReceipt("dispatch id is empty"));
4122    }
4123    if receipt.conversation_id != expected_conversation {
4124        return Err(DialError::InvalidIngressReceipt(
4125            "conversation does not match the request",
4126        ));
4127    }
4128    Ok(())
4129}
4130
4131/// Project a terminal [`AgentEnd`] into the [`TurnEvent`]s a live surface sees
4132/// at end-of-turn: one [`TurnEvent::ApprovalPending`] per paused tool call,
4133/// then a [`TurnEvent::HandoffStarted`] if the turn delegated to a sub-agent,
4134/// then one [`TurnEvent::InviteDelivery`] per admin invite minted this turn,
4135/// then a [`TurnEvent::WalletLinkPrompt`] if a paid call needed a linked
4136/// wallet (or the caller directly requested one), then a
4137/// [`TurnEvent::WalletUpdatePrompt`]/[`TurnEvent::WalletRevokePrompt`] for
4138/// their respective mints, then a
4139/// [`TurnEvent::TurnFailed`] if the turn ended durably failed, then the
4140/// terminal [`TurnEvent::Done`].
4141///
4142/// Pure (no I/O) so the End-arm mapping can be unit-tested without a live
4143/// stream, mirroring [`message_to_event`] for the Outputs arm.
4144fn events_from_end(end: AgentEnd) -> Vec<TurnEvent> {
4145    let mut events: Vec<TurnEvent> = end
4146        .pending_approvals
4147        .into_iter()
4148        .map(PendingApprovalPrompt::from)
4149        .map(TurnEvent::from)
4150        .collect();
4151    // `#1660`: the question-pause SIBLING of the `pending_approvals`
4152    // projection above — one `QuestionPending` per outstanding question.
4153    events.extend(
4154        end.pending_questions
4155            .into_iter()
4156            .map(PendingQuestionPrompt::from)
4157            .map(TurnEvent::from),
4158    );
4159    if let Some(h) = end.handoff.into_option() {
4160        events.push(TurnEvent::HandoffStarted {
4161            child_agent_id: h.child_agent_id,
4162            reason: h.reason,
4163        });
4164    }
4165    // Each minted invite becomes a delivery event for the edge to act on. The
4166    // code rides only here (control plane → edge); the edge routes it to the
4167    // target's DM and nowhere else.
4168    for d in end.invite_deliveries {
4169        events.push(TurnEvent::InviteDelivery {
4170            target_user_id: d.target_user_id,
4171            code: d.code,
4172            inviter_display: d.inviter_display,
4173        });
4174    }
4175    // A paid tool call this turn needed a linked wallet (`#519`). The wire
4176    // carries only the URL (empty = unknown); a blank/whitespace-only value
4177    // maps to `None` so the edge renders the no-button, admin-pointer card.
4178    if let Some(prompt) = end.wallet_link_prompt.into_option() {
4179        let renewal = prompt.renewal;
4180        let requested = prompt.requested;
4181        let link_url = (!prompt.link_url.trim().is_empty()).then_some(prompt.link_url);
4182        events.push(TurnEvent::WalletLinkPrompt {
4183            link_url,
4184            renewal,
4185            requested,
4186        });
4187    }
4188    // `wallet_update_limit` minted a fresh spending-limit-update ceremony
4189    // this turn (issue #1041/#1159). Always non-empty in practice — the
4190    // tool either mints a ceremony or refuses outright — mirrored here
4191    // unfiltered rather than re-adding the blank-collapses-to-`None` dance
4192    // the URL-optional prompts above need.
4193    if let Some(prompt) = end.wallet_update_prompt.into_option() {
4194        events.push(TurnEvent::WalletUpdatePrompt {
4195            update_url: prompt.update_url,
4196            new_limit: prompt.new_limit,
4197        });
4198    }
4199    // This turn's `unlink_self` (wallet target) call minted a hard-revoke
4200    // ceremony for the just-unlinked delegated key (issue #1042/#1156).
4201    if let Some(prompt) = end.wallet_revoke_prompt.into_option() {
4202        events.push(TurnEvent::WalletRevokePrompt {
4203            revoke_url: prompt.revoke_url,
4204        });
4205    }
4206    // A durably-failed turn (`#756`) ended without a `batch` — the wire's own
4207    // doc comment says this field exists so "external surfaces (Slack, the
4208    // cockpit) can react", but until this event existed no edge ever read it.
4209    if let Some(failure) = end.failure.into_option() {
4210        events.push(TurnEvent::TurnFailed {
4211            kind: turn_failure_kind_from_wire(failure.kind.to_i32()),
4212            message: failure.message,
4213        });
4214    }
4215    events.push(TurnEvent::Done);
4216    events
4217}
4218
4219/// Map the wire `TurnFailureKind` onto the local, edge-facing
4220/// [`TurnFailureKind`]. An unrecognized or unspecified wire value maps to
4221/// [`TurnFailureKind::Other`] — a definite failure with an unclear reason is
4222/// still a definite failure, never silently dropped.
4223const fn turn_failure_kind_from_wire(kind: i32) -> TurnFailureKind {
4224    if kind == WireTurnFailureKind::TURN_FAILURE_KIND_RATE_LIMIT as i32 {
4225        TurnFailureKind::RateLimit
4226    } else if kind == WireTurnFailureKind::TURN_FAILURE_KIND_TIMEOUT as i32 {
4227        TurnFailureKind::Timeout
4228    } else if kind == WireTurnFailureKind::TURN_FAILURE_KIND_UNAVAILABLE as i32 {
4229        TurnFailureKind::Unavailable
4230    } else if kind == WireTurnFailureKind::TURN_FAILURE_KIND_AUTH as i32 {
4231        TurnFailureKind::Auth
4232    } else if kind == WireTurnFailureKind::TURN_FAILURE_KIND_BAD_REQUEST as i32 {
4233        TurnFailureKind::BadRequest
4234    } else {
4235        TurnFailureKind::Other
4236    }
4237}
4238
4239/// Project a wire [`ContextCompacted`] into the corresponding
4240/// [`TurnEvent::ContextCompacted`]. Pure (no I/O); an unknown/unspecified wire
4241/// reason maps to [`CompactionReason::Truncated`] (the quieter, no-preview
4242/// rendering) so a future wire reason never panics a live surface.
4243fn event_from_compacted(c: ContextCompacted) -> TurnEvent {
4244    let reason = if c.reason.to_i32() == WireCompactionReason::COMPACTION_REASON_SUMMARIZED as i32 {
4245        CompactionReason::Summarized
4246    } else {
4247        CompactionReason::Truncated
4248    };
4249    TurnEvent::ContextCompacted {
4250        reason,
4251        summarized_messages: c.summarized_messages,
4252        summary_preview: c.summary_preview,
4253    }
4254}
4255
4256/// Map one output [`Message`] to an optional [`TurnEvent`] for the streaming
4257/// path.
4258///
4259/// Pure (no I/O) so the mapping can be unit-tested without a live stream:
4260///
4261/// - A tool-call content block, or any tool-role message, becomes a
4262///   [`TurnEvent::ToolStarted`] named for the called function (falling back to
4263///   the call id when the function name is absent).
4264/// - A non-empty text block on a model/assistant-role message becomes a
4265///   [`TurnEvent::TextDelta`].
4266/// - Tool-role text (a tool-result echo) and empty/non-textual blocks produce
4267///   no event.
4268/// - An `internal_only` message (`#743`: a paused turn's withheld model text,
4269///   or an approver/policy-injected note) produces no event — the proto
4270///   contract is that such a message is never emitted to a client, and the
4271///   streaming path is exactly that emission seam.
4272fn message_to_event(msg: Message) -> Option<TurnEvent> {
4273    if msg.internal_only {
4274        return None;
4275    }
4276    let content_block = msg.content.into_option()?;
4277    match content_block.r#type? {
4278        content::Type::ToolCall(tc) => {
4279            let name = match tc.r#type.as_ref() {
4280                Some(tool_call_content::Type::FunctionCall(fc)) if !fc.name.is_empty() => {
4281                    fc.name.clone()
4282                }
4283                _ => tc.id.clone(),
4284            };
4285            Some(TurnEvent::ToolStarted { name })
4286        }
4287        content::Type::Text(t) => {
4288            if is_assistant_role(&msg.role) && !t.text.is_empty() {
4289                Some(TurnEvent::TextDelta(t.text))
4290            } else {
4291                // Tool-role text is an intermediate result echo; empty text and
4292                // non-assistant roles carry nothing the user should see.
4293                None
4294            }
4295        }
4296        // Thoughts, tool results, and media variants have no streaming event in
4297        // this version.
4298        _ => None,
4299    }
4300}
4301
4302/// Whether a message role denotes the assistant's own answer text.
4303///
4304/// The control plane uses `model` (provider-native) and `assistant`
4305/// interchangeably for the agent's replies; both count.
4306fn is_assistant_role(role: &str) -> bool {
4307    role == "model" || role == "assistant"
4308}
4309
4310/// Render one [`content::Type`] variant as user-visible text.
4311///
4312/// Returns `None` for variants that have no useful textual representation
4313/// (image/audio/document/video/confirmation) so the `join("\n")` in
4314/// [`AgentDialer::run_turn`] doesn't emit stray blank lines.
4315///
4316/// Tool calls are rendered (rather than skipped) so a turn that ends on a
4317/// `tool_call` with no follow-up text — e.g. a HITL pause or approval-only
4318/// confirmation flow — gives the user something to see instead of an empty
4319/// body.
4320fn render_content(ty: Option<content::Type>) -> Option<String> {
4321    match ty? {
4322        content::Type::Text(t) => {
4323            if t.text.is_empty() {
4324                None
4325            } else {
4326                Some(t.text)
4327            }
4328        }
4329        content::Type::ToolCall(tc) => {
4330            let name = match tc.r#type.as_ref() {
4331                Some(tool_call_content::Type::FunctionCall(fc)) => fc.name.as_str(),
4332                None => "",
4333            };
4334            if name.is_empty() {
4335                Some(format!("[tool_call:{}]", tc.id))
4336            } else {
4337                Some(format!("[tool_call:{name} {}]", tc.id))
4338            }
4339        }
4340        content::Type::ToolResult(tr) => Some(format!("[tool_result:{}]", tr.call_id)),
4341        // Everything else renders to no reply text. Notably reasoning
4342        // (`Thought`): this helper feeds the buffered reply (and its no-text
4343        // scaffolding fallback), and surfacing reasoning would return raw
4344        // chain-of-thought as the answer on a tool-only / approval-pause turn.
4345        // Reasoning reaches the user via a separate path (the TUI builds a
4346        // collapsed thought line from the proto transcript), never here. Image /
4347        // audio / document / video / confirmation likewise have no v1 rendering.
4348        _ => None,
4349    }
4350}
4351
4352/// Classify one output [`Message`] for [`AgentDialer::run_turn_with`]'s
4353/// buffered aggregation, pushing into `text_parts` (the assistant's real
4354/// answer) or `scaffolding` (the tool-call/tool-result fallback rendering) —
4355/// pulled out of the loop body so the classification is unit-testable without
4356/// a live stream.
4357///
4358/// `#743`: an `internal_only` message — a paused turn's withheld model text,
4359/// or an approver/policy-injected note — is skipped entirely, mirroring
4360/// [`message_to_event`]'s identical rule on the streaming path so the two
4361/// APIs cannot drift on what a client is shown.
4362fn aggregate_output_message(
4363    msg: Message,
4364    text_parts: &mut Vec<String>,
4365    scaffolding: &mut Vec<String>,
4366) {
4367    if msg.internal_only {
4368        return;
4369    }
4370    // The turn stream carries every message the turn produced — including the
4371    // tool-execution result, which the control plane emits as a `tool`-role
4372    // Text block (the raw tool output, e.g. `{"now":"…"}`). Only the
4373    // `model`/`assistant` text is the user-facing answer; tool-role text is
4374    // intermediate data, not the reply.
4375    let is_assistant = matches!(msg.role.as_str(), "model" | "assistant");
4376    let Some(content_block) = msg.content.into_option() else {
4377        return;
4378    };
4379    match content_block.r#type {
4380        Some(content::Type::Text(t)) if is_assistant && !t.text.is_empty() => {
4381            text_parts.push(t.text);
4382        }
4383        // Non-assistant text (tool result echo, user) is not the reply — drop
4384        // it entirely (not even fallback).
4385        Some(content::Type::Text(_)) => {}
4386        // Tool calls/results render as scaffolding fallback; reasoning is
4387        // excluded by `render_content` (returns None) so raw chain-of-thought
4388        // never becomes the reply on a tool-only / approval-pause turn.
4389        other => {
4390            if let Some(rendered) = render_content(other) {
4391                scaffolding.push(rendered);
4392            }
4393        }
4394    }
4395}
4396
4397/// The buffered result of one turn: the aggregated reply text plus any
4398/// [`PendingApprovalPrompt`]s the turn paused on.
4399///
4400/// Returned by [`AgentDialer::run_turn_with_approvals`] — the buffered
4401/// sibling of the streaming path's `TurnEvent` sequence, folded into one
4402/// value since a buffered caller can't react mid-stream anyway.
4403#[derive(Debug, Clone, PartialEq, Eq, Default)]
4404pub struct BufferedTurn {
4405    /// The turn's aggregated answer text (identical to what
4406    /// [`AgentDialer::run_turn_with`] returns).
4407    pub reply: String,
4408    /// Gated tool calls the turn paused on, one per entry — empty unless the
4409    /// turn paused. Submit a decision via [`ApprovalDialer::respond`] for
4410    /// each, then re-drive with an empty `user_text` to resume the turn.
4411    pub pending_approvals: Vec<PendingApprovalPrompt>,
4412    /// Questions from an `ask_question` call awaiting an answer (`#1660`),
4413    /// one per entry — empty unless the turn paused. Submit a decision via
4414    /// [`QuestionDialer::respond`] for each, then re-drive with an empty
4415    /// `user_text` to resume the turn. The question-pause SIBLING of
4416    /// `pending_approvals` above.
4417    pub pending_questions: Vec<PendingQuestionPrompt>,
4418}
4419
4420/// One gated tool call a buffered turn paused on, surfaced from the terminal
4421/// `AgentEnd.pending_approvals` — the buffered-API mirror of
4422/// [`TurnEvent::ApprovalPending`]'s fields.
4423#[derive(Debug, Clone, PartialEq, Eq)]
4424pub struct PendingApprovalPrompt {
4425    /// Turn that emitted this occurrence of `request_id`.
4426    pub turn_id: String,
4427    /// Tool-call id == the approval `request_id` to answer.
4428    pub request_id: String,
4429    /// The tool/function name awaiting approval. Raw machine identifier; the
4430    /// field of record for trust/audit.
4431    pub tool_name: String,
4432    /// Human display label (MCP-style `title`) for the tool, for rendering in
4433    /// the approval prompt. May be empty; the surface then derives one from
4434    /// `tool_name`.
4435    pub title: String,
4436    /// Arguments JSON for the call.
4437    pub args_json: String,
4438    /// Why this call is gated, when the pause is an OVERRIDE of a call that
4439    /// would not otherwise need approval. Empty for an ordinary gated call;
4440    /// non-empty only for the lethal-trifecta / Rule-of-Two containment
4441    /// override.
4442    pub reason: String,
4443    /// The short-lived signed capability (`#787`) that must be replayed
4444    /// unmodified on the eventual [`ApprovalDialer::respond`] call — required,
4445    /// the control plane rejects a `Respond` whose token is missing, expired,
4446    /// or bound to a different request or conversation.
4447    pub resolve_token: String,
4448    /// Computed-preview enrichment (`#1496`); `None` for every call but
4449    /// `routine_create`. See [`ApprovalPreview`].
4450    pub preview: Option<ApprovalPreview>,
4451}
4452
4453/// Every field named explicitly, shared by both the buffered
4454/// ([`AgentDialer::run_turn_with_approvals`]) and streaming
4455/// ([`TurnEvent::ApprovalPending`]) paths so the two can't drift on what a
4456/// pending approval carries — a field added to the wire type without
4457/// updating this impl fails to compile instead of one path silently not
4458/// carrying it.
4459impl From<WireAgentPendingApproval> for PendingApprovalPrompt {
4460    fn from(pa: WireAgentPendingApproval) -> Self {
4461        Self {
4462            turn_id: pa.turn_id,
4463            request_id: pa.request_id,
4464            tool_name: pa.tool_name,
4465            title: pa.title,
4466            args_json: pa.args_json,
4467            reason: pa.reason,
4468            resolve_token: pa.resolve_token,
4469            preview: pa.preview.into_option().map(Into::into),
4470        }
4471    }
4472}
4473
4474/// One question a buffered turn paused on (`#1660`).
4475///
4476/// Surfaced from the terminal `AgentEnd.pending_questions` — the buffered-API
4477/// mirror of [`TurnEvent::QuestionPending`]'s fields. Every field named
4478/// explicitly, shared by both the buffered ([`BufferedTurn`]) and streaming
4479/// ([`TurnEvent::QuestionPending`]) paths so the two can't drift on what a
4480/// pending question carries.
4481#[derive(Debug, Clone, PartialEq, Eq)]
4482pub struct PendingQuestionPrompt {
4483    /// Turn that emitted this question occurrence (`#2523`).
4484    pub turn_id: String,
4485    /// The `ask_question` call id this question came from.
4486    pub call_id: String,
4487    /// This question's position within its call's `questions` array
4488    /// (0-based).
4489    pub index: u32,
4490    /// The short label (fits a chat-surface button-row heading).
4491    pub header: String,
4492    /// The one-sentence question to ask.
4493    pub question: String,
4494    /// 2-4 mutually exclusive options to offer.
4495    pub options: Vec<QuestionOptionPrompt>,
4496    /// The raw `ask_question` call's full arguments JSON (every question in
4497    /// the call, not just this one).
4498    pub args_json: String,
4499    /// The short-lived signed capability that must be replayed unmodified on
4500    /// the eventual [`QuestionDialer::respond`] call — required, the control
4501    /// plane rejects a `Respond` whose token is missing, expired, or bound to
4502    /// a different occurrence or conversation.
4503    pub answer_token: String,
4504    /// Whether this still-unanswered question was already surfaced once (a
4505    /// later turn's boundary marker sits after its `question_request` in
4506    /// the event log while it stayed unanswered). `false`: render the full
4507    /// interactive card; `true`: render a compact reminder instead
4508    /// (`#1970`). Derived by the control plane at replay time — never
4509    /// edge-cached.
4510    pub already_surfaced: bool,
4511}
4512
4513/// One option a pending question offers, mirroring the wire
4514/// `QuestionOption`/`QuestionOptionEntry` messages (`#1660`).
4515#[derive(Debug, Clone, PartialEq, Eq)]
4516pub struct QuestionOptionPrompt {
4517    /// A few words naming this option.
4518    pub label: String,
4519    /// The one-sentence consequence of picking this option.
4520    pub description: String,
4521    /// Whether this is the model's recommendation (at most one per
4522    /// question).
4523    pub recommended: bool,
4524}
4525
4526impl From<WireAgentQuestionOption> for QuestionOptionPrompt {
4527    fn from(o: WireAgentQuestionOption) -> Self {
4528        Self {
4529            label: o.label,
4530            description: o.description,
4531            recommended: o.recommended,
4532        }
4533    }
4534}
4535
4536impl From<QuestionOptionEntry> for QuestionOptionPrompt {
4537    fn from(o: QuestionOptionEntry) -> Self {
4538        Self {
4539            label: o.label,
4540            description: o.description,
4541            recommended: o.recommended,
4542        }
4543    }
4544}
4545
4546impl From<WireAgentPendingQuestion> for PendingQuestionPrompt {
4547    fn from(q: WireAgentPendingQuestion) -> Self {
4548        Self {
4549            turn_id: q.turn_id,
4550            call_id: q.call_id,
4551            index: q.index,
4552            header: q.header,
4553            question: q.question,
4554            options: q.options.into_iter().map(Into::into).collect(),
4555            args_json: q.args_json,
4556            answer_token: q.answer_token,
4557            already_surfaced: q.already_surfaced,
4558        }
4559    }
4560}
4561
4562/// A recovered [`PendingQuestionEntry`] (from [`QuestionDialer::list_pending`])
4563/// carries the same fields as [`PendingQuestionPrompt`] — one shared
4564/// conversion target so a `ListPending` recovery renders identically to the
4565/// live card.
4566impl From<PendingQuestionEntry> for PendingQuestionPrompt {
4567    fn from(q: PendingQuestionEntry) -> Self {
4568        Self {
4569            turn_id: q.turn_id,
4570            call_id: q.call_id,
4571            index: q.index,
4572            header: q.header,
4573            question: q.question,
4574            options: q.options.into_iter().map(Into::into).collect(),
4575            args_json: q.args_json,
4576            answer_token: q.answer_token,
4577            already_surfaced: q.already_surfaced,
4578        }
4579    }
4580}
4581
4582/// [`TurnEvent::QuestionPending`] carries the exact same fields as
4583/// [`PendingQuestionPrompt`] — reuse that conversion rather than a second
4584/// hand-written field list that could drift from it.
4585impl From<PendingQuestionPrompt> for TurnEvent {
4586    fn from(p: PendingQuestionPrompt) -> Self {
4587        Self::QuestionPending {
4588            turn_id: p.turn_id,
4589            call_id: p.call_id,
4590            index: p.index,
4591            header: p.header,
4592            question: p.question,
4593            options: p.options,
4594            args_json: p.args_json,
4595            answer_token: p.answer_token,
4596            already_surfaced: p.already_surfaced,
4597        }
4598    }
4599}
4600
4601/// [`TurnEvent::ApprovalPending`] carries the exact same fields as
4602/// [`PendingApprovalPrompt`] — reuse that conversion rather than a second
4603/// hand-written field list that could drift from it.
4604impl From<PendingApprovalPrompt> for TurnEvent {
4605    fn from(p: PendingApprovalPrompt) -> Self {
4606        Self::ApprovalPending {
4607            turn_id: p.turn_id,
4608            request_id: p.request_id,
4609            tool_name: p.tool_name,
4610            title: p.title,
4611            args_json: p.args_json,
4612            reason: p.reason,
4613            resolve_token: p.resolve_token,
4614            preview: p.preview,
4615        }
4616    }
4617}
4618
4619/// Whether this turn's `AgentEnd` carried a wallet-link prompt (`#519`), and
4620/// if so, whether a URL was known. A dedicated enum instead of
4621/// `Option<Option<String>>` (`clippy::option_option`) for the same 3-way
4622/// distinction: not this turn / this turn with no known URL / this turn with
4623/// a known URL.
4624enum WalletLinkPrompt {
4625    /// This turn carried no wallet-link signal.
4626    None,
4627    /// This turn needed a USABLE linked wallet; `link_url` is `Some(url)`
4628    /// when the deployment or per-caller link URL was known, `None` when it
4629    /// wasn't (an admin pointer is rendered instead). `renewal` (`#2122`)
4630    /// distinguishes a caller who already linked and lapsed from one who
4631    /// never linked at all — see `polyc_proto::wallet_link_prompt`.
4632    Present {
4633        link_url: Option<String>,
4634        renewal: bool,
4635        /// True when the caller directly asked to link (or replace) a
4636        /// wallet — never the payment-interrupt path `renewal` covers. See
4637        /// `polyc_proto::wallet_link_requested_prompt`.
4638        requested: bool,
4639    },
4640}
4641
4642/// Decide [`AgentDialer::run_turn_with`]'s final reply string from what the
4643/// buffered aggregation collected. Pulled out of the loop body so the
4644/// decision is unit-testable without a live stream, mirroring
4645/// [`aggregate_output_message`]'s identical rationale.
4646///
4647/// A wallet-link-needed turn (`#519` follow-up) REPLACES `text_parts`
4648/// outright with the deterministic shared-copy prompt — not an append. A
4649/// third review round found this path shares the same live-delta hazard as
4650/// Slack/Telegram (`crates/slack/src/handler.rs::interrupt_holdback_text`,
4651/// `crates/telegram/src/handler.rs`'s reply-override block): `run_turn_with`
4652/// and the streaming API dial the identical `AgentService.connect` RPC
4653/// (`build_request`), and the control plane forwards the harness's live
4654/// `TextDelta`s to EVERY client, buffered or not
4655/// (`crates/control-plane/src/grpc/mod.rs::should_emit_final_batch` skips
4656/// re-emitting the `internal_only`-filtered terminal batch whenever any delta
4657/// was forwarded — the normal case for a streaming provider). So `text_parts`
4658/// here can just as easily hold the model's own raw, unfiltered "you'll need
4659/// to link a wallet…" narration as `aggregate_output_message` never sees an
4660/// `internal_only` flag set on those live deltas (only the terminal batch
4661/// carries it, and that batch is what gets suppressed). Appending the
4662/// deterministic prompt on top of that would print both — the exact
4663/// duplicate-CTA bug this PR exists to fix, just inside one buffered message
4664/// instead of two Slack bubbles. Buffered edges (discord/email/
4665/// trigger/cli) only ever post this single final string, so a full replace
4666/// here is sufficient — there's no earlier partial render to leave stranded.
4667///
4668/// `wallet_update_prompt` (issue #1041/#1159) and `wallet_revoke_prompt`
4669/// (issue #1042/#1156) get the identical replace treatment, for the
4670/// identical reason — checked after `wallet_link_prompt`, in that order: an
4671/// arbitrary but documented tie-break for the rare case a turn somehow hits
4672/// more than one of these mutually-exclusive, distinct-tool signals at
4673/// once.
4674#[allow(clippy::too_many_arguments)] // each names a DISTINCT, mutually-exclusive prompt kind
4675fn finalize_buffered_reply(
4676    text_parts: &[String],
4677    scaffolding: &[String],
4678    wallet_link_prompt: WalletLinkPrompt,
4679    wallet_update_prompt: Option<(&str, &str)>,
4680    wallet_revoke_prompt: Option<&str>,
4681) -> String {
4682    if let WalletLinkPrompt::Present {
4683        link_url,
4684        renewal,
4685        requested,
4686    } = wallet_link_prompt
4687    {
4688        return if requested {
4689            // A direct `wallet_link` request always mints a URL before this
4690            // prompt is ever set — see `wallet_nav::link`'s doc comment —
4691            // so this always carries one.
4692            polyc_proto::wallet_link_requested_prompt(link_url.as_deref().unwrap_or_default())
4693        } else {
4694            polyc_proto::wallet_link_prompt(link_url.as_deref(), renewal)
4695        };
4696    }
4697    if let Some((url, new_limit)) = wallet_update_prompt {
4698        return polyc_proto::wallet_update_prompt(url, new_limit);
4699    }
4700    if let Some(url) = wallet_revoke_prompt {
4701        return polyc_proto::wallet_revoke_prompt(url);
4702    }
4703    if text_parts.is_empty() {
4704        scaffolding.join("\n")
4705    } else {
4706        text_parts.join("\n")
4707    }
4708}
4709
4710// ---------------------------------------------------------------------------
4711// Operator mailbox — the system-initiated, operator-authorized approval path.
4712//
4713// The control plane holds no chat tokens; the edges do (docs/reference/
4714// operator-mailbox.md). Delivery therefore splits: the control plane
4715// ORIGINATES + PERSISTS the ask and exposes a pending-notification projection
4716// (`NotificationService`); each edge DRAINS the notifications for ITS provider,
4717// DMs the operator with Approve/Deny controls, and `Ack`s. The decision comes
4718// home through `OperatorMailboxService.Decide`, where authorization lives
4719// (server-side, off the edge's already-verified inbound identity).
4720//
4721// Both services share the same internal Connect port as the other control-plane
4722// services, so they are dialed from the same `agent_addr` an edge already holds.
4723// ---------------------------------------------------------------------------
4724
4725/// A control-plane action an operator is asked to authorize.
4726///
4727/// Lifted to an edge-renderable Rust enum so surfaces match on it without
4728/// touching the wire crate. Action-agnostic; the upgrade case is the first
4729/// variant.
4730#[derive(Debug, Clone, PartialEq, Eq)]
4731pub enum OpsAction {
4732    /// Roll the cluster to a specific release.
4733    UpgradeTo {
4734        /// The target release version string, as rendered to the operator.
4735        version: String,
4736    },
4737    /// The closed-loop follow-up after an approved `UpgradeTo`: the executor
4738    /// launched the roll, then the control plane re-observed cluster state
4739    /// within a bounded window. A plain, no-decision DM reporting an outcome
4740    /// that already happened.
4741    UpgradeOutcome {
4742        /// The release version the roll targeted.
4743        version: String,
4744        /// What the closed-loop observation confirmed.
4745        outcome: UpgradeOutcomeKind,
4746    },
4747    /// An action whose variant this client does not recognise (a newer wire
4748    /// shape). Rendered generically so an edge never silently drops a real ask.
4749    Unknown,
4750}
4751
4752/// What the closed-loop rollout observation confirmed after an approved
4753/// `UpgradeTo`, in edge-renderable terms — the buffered analog of the wire
4754/// `UpgradeOutcome.Kind`.
4755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4756pub enum UpgradeOutcomeKind {
4757    /// Every changed Deployment's rollout confirmed healthy at the target
4758    /// digests.
4759    Success,
4760    /// The observation window elapsed with at least one changed Deployment
4761    /// confirmed NOT healthy at the target digests.
4762    Failed,
4763    /// The observation window elapsed without a confirmed outcome either way
4764    /// — distinct from `Failed` so the copy never claims to know the roll
4765    /// broke when it might have simply succeeded unobserved.
4766    Unknown,
4767}
4768
4769/// One undelivered `(item, target)` pair an edge must DM, in edge-friendly
4770/// terms.
4771///
4772/// Mirrors the wire [`PollPendingReply`](polyc_proto::proto::polychrome::ops::v1::PollPendingReply)
4773/// rows so an edge builds its prompt without importing `polyc-proto`.
4774#[derive(Debug, Clone, PartialEq, Eq)]
4775pub struct PendingNotice {
4776    /// Opaque idempotency id of the mailbox item — the only token an edge ever
4777    /// places in unsigned `callback_data`; state is looked up server-side.
4778    pub action_id: String,
4779    /// The DM coordinate for THIS provider: a Slack `U…` user id, or a Telegram
4780    /// `chat_id` as a decimal string.
4781    pub target: String,
4782    /// The action to render in the Approve/Deny prompt.
4783    pub action: OpsAction,
4784    /// Unix seconds at which the item expires (`0` when unset). An edge may
4785    /// surface it; a late decision is refused server-side regardless.
4786    pub expires_unix: u64,
4787    /// Lowercase-hex BLAKE3 of the canonical payload the operator is approving
4788    /// (WYSIWYS). The edge echoes this back in [`OperatorMailboxDialer::decide`].
4789    pub payload_hash: String,
4790    /// Whether this pair has already been delivered. A delivered pair is still
4791    /// surfaced so an edge can rehydrate its `action_id -> payload_hash` cache
4792    /// after a restart; the edge DMs only `delivered == false` pairs.
4793    pub delivered: bool,
4794}
4795
4796/// Reusable handle for the control plane's `NotificationService` — the
4797/// pending-notification projection an edge drains to deliver operator DMs.
4798///
4799/// Shares the `AgentService` endpoint (one internal Connect port), so it is
4800/// built from the same address an edge already holds.
4801#[derive(Clone)]
4802pub struct NotificationDialer {
4803    client: Arc<NotificationServiceClient<HttpClient>>,
4804}
4805
4806impl NotificationDialer {
4807    /// Build a dialer pointed at `addr` (expects `http://host:port`).
4808    ///
4809    /// # Errors
4810    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
4811    /// [`DialError::Tls`] if an `https` endpoint's TLS setup fails.
4812    pub fn new(addr: &str) -> Result<Self, DialError> {
4813        Ok(Self {
4814            client: build_control_client(addr, None, NotificationServiceClient::new)?,
4815        })
4816    }
4817
4818    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
4819    ///
4820    /// Every call this dialer makes carries an `Authorization: Bearer
4821    /// <bearer>` header. `NotificationService` doesn't send `AgentStart`, so
4822    /// no signed [`AssertedAttribution`] envelope rides these calls —
4823    /// bearer-only.
4824    ///
4825    /// # Errors
4826    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
4827    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
4828    /// header value.
4829    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
4830        Ok(Self {
4831            client: build_control_client(addr, Some(bearer), NotificationServiceClient::new)?,
4832        })
4833    }
4834
4835    /// Drain undelivered operator notifications for `provider` (`"slack"` |
4836    /// `"telegram"`). Read-only and idempotent — the durable mailbox is
4837    /// unchanged until [`Self::ack`].
4838    ///
4839    /// # Errors
4840    /// Returns [`DialError::Connect`] for any transport/encoding error.
4841    pub async fn poll_pending(&self, provider: &str) -> Result<Vec<PendingNotice>, DialError> {
4842        let request = PollPendingRequest {
4843            provider: provider.to_owned(),
4844            ..Default::default()
4845        };
4846        let reply = self
4847            .client
4848            .poll_pending_with_options(request, traced_options())
4849            .await?
4850            .into_owned();
4851        Ok(reply.pending.into_iter().map(pending_notice).collect())
4852    }
4853
4854    /// Mark an `(action_id, target)` delivered after the DM is sent. Persists
4855    /// an `ops_action_delivered` marker server-side so the projection survives
4856    /// restart and never double-delivers. Idempotent: a redundant ack is
4857    /// harmless.
4858    ///
4859    /// # Errors
4860    /// Returns [`DialError::Connect`] for any transport/encoding error.
4861    pub async fn ack(
4862        &self,
4863        provider: &str,
4864        action_id: &str,
4865        target: &str,
4866    ) -> Result<bool, DialError> {
4867        let request = AckRequest {
4868            provider: provider.to_owned(),
4869            action_id: action_id.to_owned(),
4870            target: target.to_owned(),
4871            ..Default::default()
4872        };
4873        let reply = self
4874            .client
4875            .ack_with_options(request, traced_options())
4876            .await?
4877            .into_owned();
4878        Ok(reply.persisted)
4879    }
4880
4881    /// Server-streaming replacement for [`Self::poll_pending`] (#803): opens a
4882    /// long-lived `Subscribe` stream that pushes undelivered notifications for
4883    /// `provider` as they're durably recorded, instead of a fixed polling
4884    /// interval. The initial connect ALSO catches a freshly (re)started edge up
4885    /// on whatever is already undelivered — the same coverage a first
4886    /// `poll_pending` call would give, just pushed rather than pulled.
4887    ///
4888    /// # Errors
4889    /// The outer `Result` carries [`DialError::Connect`] if opening the stream
4890    /// fails. Each item is a `Result` so a later transport/decode error
4891    /// surfaces inline without tearing down the whole stream.
4892    pub async fn subscribe(
4893        &self,
4894        provider: &str,
4895    ) -> Result<impl Stream<Item = Result<PendingNotice, DialError>>, DialError> {
4896        let request = SubscribeRequest {
4897            provider: provider.to_owned(),
4898            ..Default::default()
4899        };
4900        let mut stream = self
4901            .client
4902            .subscribe_with_options(request, traced_options())
4903            .await?;
4904        Ok(async_stream::try_stream! {
4905            while let Some(view) = stream.message().await? {
4906                yield pending_notice(view.to_owned_message());
4907            }
4908        })
4909    }
4910}
4911
4912/// Map a wire [`PendingNotification`](polyc_proto::proto::polychrome::ops::v1::PendingNotification)
4913/// to the edge-friendly [`PendingNotice`], lifting the action oneof to
4914/// [`OpsAction`].
4915fn pending_notice(
4916    p: polyc_proto::proto::polychrome::ops::v1::PendingNotification,
4917) -> PendingNotice {
4918    let action = p.action.into_option().and_then(|view| view.action).map_or(
4919        OpsAction::Unknown,
4920        |a| match a {
4921            ops_action_view::Action::UpgradeTo(u) => OpsAction::UpgradeTo { version: u.version },
4922            ops_action_view::Action::UpgradeOutcome(u) => OpsAction::UpgradeOutcome {
4923                version: u.version,
4924                outcome: match u.kind.as_known() {
4925                    Some(upgrade_outcome::Kind::SUCCESS) => UpgradeOutcomeKind::Success,
4926                    Some(upgrade_outcome::Kind::FAILED) => UpgradeOutcomeKind::Failed,
4927                    Some(
4928                        upgrade_outcome::Kind::UNKNOWN | upgrade_outcome::Kind::KIND_UNSPECIFIED,
4929                    )
4930                    | None => UpgradeOutcomeKind::Unknown,
4931                },
4932            },
4933        },
4934    );
4935    PendingNotice {
4936        action_id: p.action_id,
4937        target: p.target,
4938        action,
4939        expires_unix: p.expires_unix,
4940        payload_hash: p.payload_hash,
4941        delivered: p.delivered,
4942    }
4943}
4944
4945/// Reusable handle for the control plane's `RoutineService` — the
4946/// manual-fire dev-path affordance (`#1369`).
4947///
4948/// The routine-enrollment ceremony trigger this type used to also carry was
4949/// deleted in #1551: the prompt-routines pivot removed edge delivery and
4950/// made sharing duplication-first, so no subscription/enrollment
4951/// relationship returns.
4952///
4953/// Shares the `AgentService` endpoint (one internal Connect port), so it is
4954/// built from the same address an edge already holds.
4955#[derive(Clone)]
4956pub struct RoutineDialer {
4957    client: Arc<RoutineServiceClient<HttpClient>>,
4958}
4959
4960impl RoutineDialer {
4961    /// Build a dialer pointed at `addr` (expects `http://host:port`).
4962    ///
4963    /// # Errors
4964    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
4965    /// [`DialError::Tls`] if an `https` endpoint's TLS setup fails.
4966    pub fn new(addr: &str) -> Result<Self, DialError> {
4967        Ok(Self {
4968            client: build_control_client(addr, None, RoutineServiceClient::new)?,
4969        })
4970    }
4971
4972    /// Manually fire a routine right now (`#1369`) — the
4973    /// standard way to test any routine without waiting for its schedule.
4974    ///
4975    /// `occurrence` empty mints one from the current time
4976    /// (`{routine}-{epoch_minutes}`); non-empty is used verbatim, so a caller
4977    /// can pin (or replay) an exact occurrence. The control plane's admin
4978    /// gate (`#1489`) requires, in order: `admin_bearer` presented as
4979    /// `Authorization: Bearer <admin_bearer>` — the SAME admin-service
4980    /// credential scheme `POLYCHROME_ADMIN_SERVICE_KEYS` seeds — verified
4981    /// BEFORE the body is trusted at all, and only then `actor` resolved to
4982    /// an admin persona. Neither alone is enough — an asserted `actor` with
4983    /// no valid credential is refused just as a valid credential naming a
4984    /// non-admin `actor` is.
4985    ///
4986    /// Post-`#1595` this is a clean no-op beyond routine resolution and
4987    /// occurrence minting — the fixed-content fire path this call used to
4988    /// hand resolved content templates to is retired.
4989    ///
4990    /// `admin_bearer` is a per-call header, not one of this dialer's default
4991    /// headers (`RoutineDialer::new` dials without a bearer), so it goes
4992    /// through the crate's shared sensitive-`HeaderValue` construction site
4993    /// directly rather than the shared default-`HeaderMap` builder every
4994    /// other dialer's `with_bearer` uses — same construction, same
4995    /// `set_sensitive` marking, just built per call instead of once at dial
4996    /// time.
4997    ///
4998    /// # Errors
4999    /// Returns [`DialError::InvalidBearer`] if `admin_bearer` can't be
5000    /// encoded as an HTTP header value, or [`DialError::Connect`] for any
5001    /// transport/encoding error, which carries the control plane's Connect
5002    /// error (unknown routine, a missing or invalid `admin_bearer`, a
5003    /// non-admin `actor`, or an unconfigured harness).
5004    pub async fn fire_routine(
5005        &self,
5006        routine: &str,
5007        occurrence: &str,
5008        actor: ExternalIdentity,
5009        admin_bearer: &str,
5010    ) -> Result<FiredRoutine, DialError> {
5011        let request = FireRoutineRequest {
5012            routine: routine.to_owned(),
5013            occurrence: occurrence.to_owned(),
5014            actor: buffa::MessageField::some(actor),
5015            ..Default::default()
5016        };
5017        let options = traced_options().with_header(
5018            http::header::AUTHORIZATION,
5019            bearer_header_value(admin_bearer)?,
5020        );
5021        let reply = self
5022            .client
5023            .fire_routine_with_options(request, options)
5024            .await?
5025            .into_owned();
5026        Ok(FiredRoutine {
5027            occurrence: reply.occurrence,
5028        })
5029    }
5030}
5031
5032/// The result of [`RoutineDialer::fire_routine`]: the occurrence the firing used.
5033///
5034/// Post-`#1595` this is the whole reply — the fixed-content fire path that
5035/// used to report one outcome per content template is retired.
5036#[derive(Debug, Clone, PartialEq, Eq)]
5037pub struct FiredRoutine {
5038    /// The occurrence identity this firing used (the caller's, or minted).
5039    pub occurrence: String,
5040}
5041
5042/// A credential key's lifecycle state.
5043///
5044/// Buffered off the wire enum so downstream code (the CLI's `edge` verbs)
5045/// matches on a closed Rust enum instead of taking a `polyc-proto` dependency
5046/// of its own — this crate is the one place that talks connectrpc/buffa/proto
5047/// directly on this path, mirroring how [`CompactionReason`] already buffers
5048/// off its own wire enum.
5049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5050pub enum CredentialKeyState {
5051    /// Verifies now — the credential's currently-presented key.
5052    Active,
5053    /// Still verifies, but is being phased out. The rotation runbook's step
5054    /// 4 watches this state's verification traffic go quiet before step 5
5055    /// (`CredentialDialer::retire_credential_key`) retires it.
5056    Retiring,
5057    /// Never verifies again.
5058    Revoked,
5059    /// A lifecycle value this client doesn't recognize yet. Reported
5060    /// distinctly rather than guessed at, since a mutation must never assert
5061    /// a state it can't read.
5062    Unknown,
5063}
5064
5065impl From<buffa::EnumValue<WireCredentialKeyState>> for CredentialKeyState {
5066    fn from(state: buffa::EnumValue<WireCredentialKeyState>) -> Self {
5067        match state {
5068            buffa::EnumValue::Known(WireCredentialKeyState::KEY_STATE_ACTIVE) => Self::Active,
5069            buffa::EnumValue::Known(WireCredentialKeyState::KEY_STATE_RETIRING) => Self::Retiring,
5070            buffa::EnumValue::Known(WireCredentialKeyState::KEY_STATE_REVOKED) => Self::Revoked,
5071            buffa::EnumValue::Known(WireCredentialKeyState::KEY_STATE_UNSPECIFIED)
5072            | buffa::EnumValue::Unknown(_) => Self::Unknown,
5073        }
5074    }
5075}
5076
5077/// One credential key's operator-visible summary — the buffered analog of
5078/// the wire `CredentialKeySummary`. Carries no salt, digest, or public key:
5079/// the control plane never sends one (INV-C3).
5080#[derive(Debug, Clone, PartialEq, Eq)]
5081pub struct CredentialKeySummary {
5082    /// The key id — what `retire_credential_key` names, and what the audit
5083    /// trail and the retiring-verification metric record.
5084    pub kid: String,
5085    /// This key's lifecycle state.
5086    pub state: CredentialKeyState,
5087    /// When this key became active (unix ms).
5088    pub activated_at_ms: u64,
5089    /// Hard expiry (unix ms), if one was set.
5090    pub not_after_ms: Option<u64>,
5091    /// Unix ms this key FIRST verified a bearer successfully, if it ever
5092    /// has. `None` means it never has yet — the ordinary state for a
5093    /// freshly enrolled or freshly rotated-in key, not a fault (issue
5094    /// #1696). Recorded once and never updated again: a first-use
5095    /// timestamp, not a last-verified one.
5096    pub confirmed_at_ms: Option<u64>,
5097}
5098
5099impl From<WireCredentialKeySummary> for CredentialKeySummary {
5100    fn from(wire: WireCredentialKeySummary) -> Self {
5101        Self {
5102            kid: wire.kid,
5103            state: wire.state.into(),
5104            activated_at_ms: wire.activated_at_ms,
5105            not_after_ms: wire.not_after_ms,
5106            confirmed_at_ms: wire.confirmed_at_ms,
5107        }
5108    }
5109}
5110
5111/// One stored credential's operator-visible summary — the buffered analog of
5112/// the wire `CredentialSummary`.
5113#[derive(Debug, Clone, PartialEq, Eq)]
5114pub struct CredentialRecordSummary {
5115    /// The caller identity string this record is keyed by.
5116    pub edge_id: String,
5117    /// Stable subject recorded for audit; survives key rotation.
5118    pub principal: String,
5119    /// Conversation-id namespace prefixes this record may write into.
5120    pub allowed_namespaces: Vec<String>,
5121    /// This record's key set, oldest entry first.
5122    pub keys: Vec<CredentialKeySummary>,
5123    /// Unix ms this credential was revoked, if it has been. Present means the
5124    /// record is a tombstone: it verifies nothing and never will again, and
5125    /// it is listed only so an operator can see when it was revoked. Any
5126    /// rendering of this record MUST say so — a revoked credential must never
5127    /// read as live.
5128    pub revoked_at_ms: Option<u64>,
5129    /// Whether this record clears the control plane's transport gate and may
5130    /// sign a turn's identity envelope.
5131    pub grants_edge: bool,
5132    /// Whether this record is accepted as the admin service credential.
5133    pub grants_admin: bool,
5134    /// Whether this record is accepted as the backend-email-attestation
5135    /// service credential (`PersonaService.AttestVerifiedEmail`).
5136    pub grants_attest_email: bool,
5137}
5138
5139impl From<WireCredentialSummary> for CredentialRecordSummary {
5140    fn from(wire: WireCredentialSummary) -> Self {
5141        Self {
5142            edge_id: wire.edge_id,
5143            principal: wire.principal,
5144            allowed_namespaces: wire.allowed_namespaces,
5145            revoked_at_ms: wire.revoked_at_ms,
5146            grants_edge: wire.grants_edge,
5147            grants_admin: wire.grants_admin,
5148            grants_attest_email: wire.grants_attest_email,
5149            keys: wire
5150                .keys
5151                .into_iter()
5152                .map(CredentialKeySummary::from)
5153                .collect(),
5154        }
5155    }
5156}
5157
5158/// The verifier half of one credential key, as an operator authors it.
5159///
5160/// The salted digest of a secret they wrote to their own secret manager, plus
5161/// the public key of the pair that secret's holder signs identity envelopes
5162/// with. Never a raw secret — [`CredentialDialer::enroll_credential`] and
5163/// [`CredentialDialer::add_credential_key`] don't accept one: the control
5164/// plane never mints or holds the secret itself.
5165#[derive(Debug, Clone)]
5166pub struct CredentialVerifier {
5167    /// Stable, operator-visible key id, unique within its record.
5168    pub kid: String,
5169    /// Per-key salt mixed into the digest (not secret).
5170    pub salt: String,
5171    /// `hex(sha256(salt || secret))`. The secret itself is never sent.
5172    pub secret_sha256: String,
5173    /// The ed25519 public key this key signs identity envelopes with,
5174    /// hex-encoded.
5175    pub signer_pk_hex: String,
5176    /// Hard expiry (unix ms), independent of lifecycle state. `None` means
5177    /// no expiry.
5178    pub not_after_ms: Option<u64>,
5179}
5180
5181/// One idempotent credential-enrollment operation.
5182///
5183/// `operation_id` is caller-stable: reuse the same value, with the same
5184/// fields, after an ambiguous transport failure. State returns the original
5185/// outcome instead of applying the enrollment twice.
5186#[derive(Debug, Clone)]
5187pub struct CredentialEnrollment {
5188    /// Stable identity for this logical enrollment attempt.
5189    pub operation_id: String,
5190    /// Stable credential-record id.
5191    pub edge_id: String,
5192    /// Principal authenticated by the credential.
5193    pub principal: String,
5194    /// Namespace patterns this credential may enter.
5195    pub allowed_namespaces: Vec<String>,
5196    /// First active verifier for the record.
5197    pub key: CredentialVerifier,
5198    /// Whether this credential may enter through an edge transport.
5199    pub grants_edge: bool,
5200    /// Whether this credential may call admin-gated services.
5201    pub grants_admin: bool,
5202    /// Whether this credential is accepted as the backend-email-attestation
5203    /// service credential (`PersonaService.AttestVerifiedEmail`).
5204    pub grants_attest_email: bool,
5205}
5206
5207impl From<CredentialVerifier> for WireCredentialKeyVerifier {
5208    fn from(verifier: CredentialVerifier) -> Self {
5209        Self {
5210            kid: verifier.kid,
5211            salt: verifier.salt,
5212            secret_sha256: verifier.secret_sha256,
5213            signer_pk_hex: verifier.signer_pk_hex,
5214            not_after_ms: verifier.not_after_ms,
5215            __buffa_unknown_fields: buffa::UnknownFields::default(),
5216        }
5217    }
5218}
5219
5220/// What [`CredentialDialer::enroll_credential`] did.
5221#[derive(Debug, Clone, PartialEq, Eq)]
5222pub struct EnrolledCredential {
5223    /// The enrolled record's id.
5224    pub edge_id: String,
5225    /// The id of the key that is now active.
5226    pub kid: String,
5227}
5228
5229/// One bounded page from credential lifecycle administration.
5230#[derive(Debug, Clone, PartialEq, Eq)]
5231pub struct CredentialSummaryPage {
5232    /// Records in canonical credential-id order.
5233    pub records: Vec<CredentialRecordSummary>,
5234    /// Resume cursor when another record exists.
5235    pub next_after: Option<String>,
5236    /// Authority revision at which this page was read.
5237    pub snapshot_revision: u64,
5238}
5239
5240/// What [`CredentialDialer::add_credential_key`] did.
5241#[derive(Debug, Clone, PartialEq, Eq)]
5242pub struct AddedCredentialKey {
5243    /// The record that was changed.
5244    pub edge_id: String,
5245    /// The id of the key that is now active.
5246    pub kid: String,
5247    /// The ids of the keys that dropped from active to retiring. They keep
5248    /// verifying until [`CredentialDialer::retire_credential_key`] names
5249    /// them, so nothing is disrupted (INV-C2).
5250    pub retiring_kids: Vec<String>,
5251}
5252
5253/// What [`CredentialDialer::retire_credential_key`] did.
5254#[derive(Debug, Clone, PartialEq, Eq)]
5255pub struct RetiredCredentialKey {
5256    /// The record that was changed.
5257    pub edge_id: String,
5258    /// The key that is now revoked.
5259    pub kid: String,
5260}
5261
5262/// What [`CredentialDialer::revoke_credential`] did.
5263#[derive(Debug, Clone, PartialEq, Eq)]
5264pub struct RevokedCredential {
5265    /// The record that was removed.
5266    pub edge_id: String,
5267}
5268
5269/// Reusable handle for the control plane's `CredentialService` — the
5270/// admin-gated live enrollment, rotation, and revocation surface over State's
5271/// credential authority.
5272///
5273/// Shares the `AgentService` endpoint — all these services are served on one
5274/// internal Connect port — so it is built from the same address. Every RPC on
5275/// this service requires the `#803` admin service credential before it looks
5276/// at anything else in the request body, so unlike [`PersonaDialer`] this
5277/// dialer has no unauthenticated `new`: it is built ONLY through
5278/// [`Self::new_admin`].
5279#[derive(Clone)]
5280pub struct CredentialDialer {
5281    client: Arc<CredentialServiceClient<HttpClient>>,
5282}
5283
5284impl CredentialDialer {
5285    /// Build a dialer pointed at `addr`, presenting `bearer` as the `#803`
5286    /// admin service credential (`Authorization: Bearer pc_<id>_<secret>`,
5287    /// a current record in State's credential authority carrying admin
5288    /// access) every `CredentialService` RPC checks before it will even look
5289    /// at the request body.
5290    ///
5291    /// # Errors
5292    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI,
5293    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
5294    /// header value, or [`DialError::Tls`] if an `https` endpoint's TLS setup
5295    /// fails.
5296    pub fn new_admin(addr: &str, bearer: &str) -> Result<Self, DialError> {
5297        Ok(Self {
5298            client: build_control_client(addr, Some(bearer), CredentialServiceClient::new)?,
5299        })
5300    }
5301
5302    /// One bounded page of stored records: id, principal, namespaces, and
5303    /// each key's id, lifecycle state, and expiry. Never any secret material
5304    /// — the control plane refuses to send a salt, digest, or public key
5305    /// (INV-C3).
5306    ///
5307    /// # Errors
5308    /// Returns [`DialError::Connect`] for any transport/encoding error,
5309    /// including `permission_denied` for a missing or invalid admin bearer.
5310    pub async fn list_credentials_page(
5311        &self,
5312        after: Option<&str>,
5313    ) -> Result<CredentialSummaryPage, DialError> {
5314        let reply = self
5315            .client
5316            .list_credentials_with_options(
5317                ListCredentialsRequest {
5318                    after: after.map(str::to_owned),
5319                    // CredentialService's public protocol pins the same
5320                    // count ceiling as State's typed page contract.
5321                    limit: 64,
5322                    __buffa_unknown_fields: buffa::UnknownFields::default(),
5323                },
5324                traced_options(),
5325            )
5326            .await?
5327            .into_owned();
5328        if reply.records.len() > 64
5329            || reply.next_after.as_ref().is_some_and(|next| {
5330                reply.records.last().map(|record| &record.edge_id) != Some(next)
5331                    || after.is_some_and(|prior| next.as_str() <= prior)
5332            })
5333        {
5334            return Err(connectrpc::ConnectError::internal(
5335                "credential authority returned an invalid page cursor",
5336            )
5337            .into());
5338        }
5339        Ok(CredentialSummaryPage {
5340            records: reply
5341                .records
5342                .into_iter()
5343                .map(CredentialRecordSummary::from)
5344                .collect(),
5345            next_after: reply.next_after,
5346            snapshot_revision: reply.snapshot_revision,
5347        })
5348    }
5349
5350    /// Reads every bounded page in canonical order.
5351    ///
5352    /// The protocol remains paginated, so no response can exceed the
5353    /// credential-family wire bound. This convenience method is itself
5354    /// bounded by State's deployment-wide credential-count limit.
5355    ///
5356    /// # Errors
5357    /// Returns [`DialError::Connect`] for any page transport/encoding error.
5358    pub async fn list_credentials(&self) -> Result<Vec<CredentialRecordSummary>, DialError> {
5359        for _ in 0..3 {
5360            let mut records = Vec::new();
5361            let mut after = None;
5362            let mut revision = None;
5363            let coherent = loop {
5364                let page = self.list_credentials_page(after.as_deref()).await?;
5365                if revision.is_some_and(|expected| expected != page.snapshot_revision) {
5366                    break false;
5367                }
5368                revision.get_or_insert(page.snapshot_revision);
5369                records.extend(page.records);
5370                if records.len() > 1_024 {
5371                    return Err(connectrpc::ConnectError::internal(
5372                        "credential authority exceeded its directory bound",
5373                    )
5374                    .into());
5375                }
5376                let Some(next) = page.next_after else {
5377                    break true;
5378                };
5379                after = Some(next);
5380            };
5381            if coherent {
5382                return Ok(records);
5383            }
5384        }
5385        Err(connectrpc::ConnectError::aborted(
5386            "credential authority changed throughout the bounded listing retry",
5387        )
5388        .into())
5389    }
5390
5391    /// Create a record with its first key from an operator-supplied
5392    /// verifier. Refuses (`already_exists`) if `edge_id` is already enrolled
5393    /// — add a key to rotate an existing record instead.
5394    ///
5395    /// `grants_edge` admits the record at the control plane's transport gate
5396    /// and lets its keys sign a turn's identity envelope; `grants_admin`
5397    /// makes it an admin service credential. A record needs at least one, and
5398    /// a credential that must reach a doubly-gated RPC — every
5399    /// `PersonaService` admin verb, `RoutineService.FireRoutine` — needs
5400    /// both, since one presented bearer clears two independent checks.
5401    ///
5402    /// # Errors
5403    /// Returns [`DialError::Connect`] for any transport/encoding error,
5404    /// including `invalid_argument` when neither capability is granted.
5405    pub async fn enroll_credential(
5406        &self,
5407        enrollment: CredentialEnrollment,
5408    ) -> Result<EnrolledCredential, DialError> {
5409        let request = EnrollCredentialRequest {
5410            operation_id: enrollment.operation_id,
5411            edge_id: enrollment.edge_id,
5412            principal: enrollment.principal,
5413            allowed_namespaces: enrollment.allowed_namespaces,
5414            key: buffa::MessageField::some(enrollment.key.into()),
5415            grants_edge: enrollment.grants_edge,
5416            grants_admin: enrollment.grants_admin,
5417            grants_attest_email: enrollment.grants_attest_email,
5418            ..Default::default()
5419        };
5420        let reply = self
5421            .client
5422            .enroll_credential_with_options(request, traced_options())
5423            .await?
5424            .into_owned();
5425        Ok(EnrolledCredential {
5426            edge_id: reply.edge_id,
5427            kid: reply.kid,
5428        })
5429    }
5430
5431    /// Append a new active key to `edge_id` — step 2 of a rotation. The
5432    /// key(s) currently active drop to retiring and keep verifying until
5433    /// [`Self::retire_credential_key`] names them (INV-C2): there is no
5434    /// instant in which a correctly-configured caller is refused.
5435    ///
5436    /// # Errors
5437    /// Returns [`DialError::Connect`] for any transport/encoding error,
5438    /// including `not_found` for an unknown `edge_id`.
5439    pub async fn add_credential_key(
5440        &self,
5441        operation_id: &str,
5442        edge_id: &str,
5443        key: CredentialVerifier,
5444    ) -> Result<AddedCredentialKey, DialError> {
5445        let request = AddCredentialKeyRequest {
5446            operation_id: operation_id.to_owned(),
5447            edge_id: edge_id.to_owned(),
5448            key: buffa::MessageField::some(key.into()),
5449            ..Default::default()
5450        };
5451        let reply = self
5452            .client
5453            .add_credential_key_with_options(request, traced_options())
5454            .await?
5455            .into_owned();
5456        Ok(AddedCredentialKey {
5457            edge_id: reply.edge_id,
5458            kid: reply.kid,
5459            retiring_kids: reply.retiring_kids,
5460        })
5461    }
5462
5463    /// Mark a retiring key revoked — step 5 of a rotation. It never
5464    /// verifies again.
5465    ///
5466    /// # Errors
5467    /// Returns [`DialError::Connect`] for any transport/encoding error,
5468    /// including `failed_precondition` when `kid` isn't currently retiring.
5469    pub async fn retire_credential_key(
5470        &self,
5471        operation_id: &str,
5472        edge_id: &str,
5473        kid: &str,
5474    ) -> Result<RetiredCredentialKey, DialError> {
5475        let request = RetireCredentialKeyRequest {
5476            operation_id: operation_id.to_owned(),
5477            edge_id: edge_id.to_owned(),
5478            kid: kid.to_owned(),
5479            ..Default::default()
5480        };
5481        let reply = self
5482            .client
5483            .retire_credential_key_with_options(request, traced_options())
5484            .await?
5485            .into_owned();
5486        Ok(RetiredCredentialKey {
5487            edge_id: reply.edge_id,
5488            kid: reply.kid,
5489        })
5490    }
5491
5492    /// Kill a whole credential immediately. Every one of its keys stops
5493    /// verifying and the record is removed from the store, so a restart
5494    /// cannot bring it back (INV-C1).
5495    ///
5496    /// # Errors
5497    /// Returns [`DialError::Connect`] for any transport/encoding error,
5498    /// including `not_found` for an unknown `edge_id`.
5499    pub async fn revoke_credential(
5500        &self,
5501        operation_id: &str,
5502        edge_id: &str,
5503    ) -> Result<RevokedCredential, DialError> {
5504        let request = RevokeCredentialRequest {
5505            operation_id: operation_id.to_owned(),
5506            edge_id: edge_id.to_owned(),
5507            ..Default::default()
5508        };
5509        let reply = self
5510            .client
5511            .revoke_credential_with_options(request, traced_options())
5512            .await?
5513            .into_owned();
5514        Ok(RevokedCredential {
5515            edge_id: reply.edge_id,
5516        })
5517    }
5518}
5519
5520/// The control plane's verdict on an operator decision, in edge-renderable
5521/// terms — the buffered analog of the wire `DecideReply.Outcome`.
5522#[derive(Debug, Clone, PartialEq, Eq)]
5523pub enum DecideOutcome {
5524    /// Approved, authorized, signed + recorded, handed to the executor.
5525    Applied,
5526    /// The operator denied it; the denial was recorded.
5527    Denied,
5528    /// Refused — not authorized to approve / unknown action / already decided /
5529    /// expired / payload drift. `detail` carries the human-readable reason.
5530    Rejected,
5531    /// An unspecified/unknown outcome — the edge surfaces a generic failure
5532    /// rather than claiming success.
5533    Unknown,
5534}
5535
5536/// An operator decision's result: the [`DecideOutcome`] plus the control
5537/// plane's human-readable `detail` (the reject reason when rejected).
5538#[derive(Debug, Clone, PartialEq, Eq)]
5539pub struct DecideResult {
5540    /// The verdict.
5541    pub outcome: DecideOutcome,
5542    /// Human-readable detail to surface to the operator.
5543    pub detail: String,
5544}
5545
5546/// Heading shown above an approval-mailbox prompt.
5547///
5548/// Shared across edges so the two surfaces never word the same ask differently,
5549/// and to keep internal jargon out of user-facing copy — an edge only wraps this
5550/// in its own markup.
5551pub const OPS_PROMPT_HEADING: &str = "Approval needed";
5552
5553impl OpsAction {
5554    /// Plain description of the action for an approval prompt — the single
5555    /// wording both edges render, so an added action variant is described once
5556    /// and never diverges between surfaces.
5557    ///
5558    /// An `UpgradeTo` ask is worded through the shared
5559    /// [`update_copy`](polyc_runtime::update_copy::update_copy) helper — the same
5560    /// one the CLI `status` line and the dashboard banner use — so a person reads
5561    /// the identical "update ready" copy wherever it surfaces. Rolling the cluster
5562    /// to a new release replaces the running image, so the change is
5563    /// [`Compatibility::Warm`](polyc_runtime::compat::Compatibility::Warm): it
5564    /// restarts the service, and conversations already underway finish first.
5565    #[must_use]
5566    pub fn summary(&self) -> String {
5567        match self {
5568            Self::UpgradeTo { version } => {
5569                let copy = polyc_runtime::update_copy::update_copy(
5570                    &polyc_runtime::compat::Compatibility::Warm,
5571                    version,
5572                );
5573                format!("{} — {}", copy.headline, copy.detail)
5574            }
5575            // The closed-loop follow-up: reports what the approved roll
5576            // actually did, never claiming success it couldn't confirm.
5577            Self::UpgradeOutcome { version, outcome } => match outcome {
5578                UpgradeOutcomeKind::Success => {
5579                    format!("✅ The cluster is now running {version}.")
5580                }
5581                UpgradeOutcomeKind::Failed => format!(
5582                    "⚠️ The upgrade to {version} did not confirm healthy — check `polychrome status`."
5583                ),
5584                UpgradeOutcomeKind::Unknown => format!(
5585                    "❓ Could not confirm whether the upgrade to {version} finished — check `polychrome status`."
5586                ),
5587            },
5588            // Jargon-free fallback for an action this client doesn't recognize.
5589            Self::Unknown => "Approve a pending action".to_owned(),
5590        }
5591    }
5592
5593    /// The exact verb an edge puts on the approve affordance for this action, or
5594    /// `None` to fall back to a generic "Approve".
5595    ///
5596    /// An `UpgradeTo` ask that can be applied in place carries the shared
5597    /// [`APPLY_NOW`](polyc_runtime::update_copy::APPLY_NOW) verb, routed through
5598    /// the same helper as [`Self::summary`] so the button never words the update
5599    /// differently from its detail. A cold or incompatible change would carry no
5600    /// verb here; a cluster roll is always warm, so the upgrade ask carries the
5601    /// apply verb.
5602    #[must_use]
5603    pub fn approve_verb(&self) -> Option<&'static str> {
5604        match self {
5605            Self::UpgradeTo { version } => {
5606                polyc_runtime::update_copy::update_copy(
5607                    &polyc_runtime::compat::Compatibility::Warm,
5608                    version,
5609                )
5610                .action
5611            }
5612            Self::UpgradeOutcome { .. } | Self::Unknown => None,
5613        }
5614    }
5615
5616    /// Whether this action carries an Approve/Deny decision at all.
5617    ///
5618    /// An [`OpsAction::UpgradeOutcome`] reports something that already happened,
5619    /// not an ask.
5620    /// Every other known action (and `Unknown`, defensively — a future wire
5621    /// shape this client doesn't recognize yet still gets rendered as a
5622    /// decidable ask rather than silently dropped) is decidable.
5623    #[must_use]
5624    pub const fn is_decision(&self) -> bool {
5625        !matches!(self, Self::UpgradeOutcome { .. })
5626    }
5627}
5628
5629impl DecideOutcome {
5630    /// Metric label for this outcome (`applied` | `denied` | `rejected` |
5631    /// `unknown`) — shared so the two edges never emit divergent label sets for
5632    /// the same control-plane verdict.
5633    #[must_use]
5634    pub const fn metric_label(&self) -> &'static str {
5635        match self {
5636            Self::Applied => "applied",
5637            Self::Denied => "denied",
5638            Self::Rejected => "rejected",
5639            Self::Unknown => "unknown",
5640        }
5641    }
5642}
5643
5644impl DecideResult {
5645    /// The decided-state line that REPLACES an approval prompt after a decision
5646    /// (the double-click guard drops the buttons). Surfaces the control plane's
5647    /// verdict and, on a rejection, its `detail`. Shared so both edges show the
5648    /// same wording for the same verdict.
5649    #[must_use]
5650    pub fn decided_line(&self, decider: &str) -> String {
5651        match self.outcome {
5652            DecideOutcome::Applied => format!("✅ Approved by {decider} — applying."),
5653            DecideOutcome::Denied => format!("🚫 Denied by {decider}."),
5654            DecideOutcome::Rejected => {
5655                let why = if self.detail.is_empty() {
5656                    "not authorized or no longer valid"
5657                } else {
5658                    self.detail.as_str()
5659                };
5660                format!("⛔ Rejected — {why}.")
5661            }
5662            DecideOutcome::Unknown => {
5663                "⚠️ Something went wrong recording that decision — try again.".to_owned()
5664            }
5665        }
5666    }
5667}
5668
5669/// Reusable handle for the control plane's `OperatorMailboxService` — the
5670/// operator decision endpoint (authorize → evaluate → sign → record →
5671/// executor, all server-side).
5672///
5673/// Shares the `AgentService` endpoint (one internal Connect port), so it is
5674/// built from the same address an edge already holds.
5675#[derive(Clone)]
5676pub struct OperatorMailboxDialer {
5677    client: Arc<OperatorMailboxServiceClient<HttpClient>>,
5678}
5679
5680impl OperatorMailboxDialer {
5681    /// Build a dialer pointed at `addr` (expects `http://host:port`).
5682    ///
5683    /// # Errors
5684    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
5685    /// [`DialError::Tls`] if an `https` endpoint's TLS setup fails.
5686    pub fn new(addr: &str) -> Result<Self, DialError> {
5687        Ok(Self {
5688            client: build_control_client(addr, None, OperatorMailboxServiceClient::new)?,
5689        })
5690    }
5691
5692    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
5693    ///
5694    /// Every call this dialer makes carries an `Authorization: Bearer
5695    /// <bearer>` header. `OperatorMailboxService` doesn't send `AgentStart`,
5696    /// so no signed [`AssertedAttribution`] envelope rides these calls —
5697    /// bearer-only.
5698    ///
5699    /// # Errors
5700    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
5701    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
5702    /// header value.
5703    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
5704        Ok(Self {
5705            client: build_control_client(addr, Some(bearer), OperatorMailboxServiceClient::new)?,
5706        })
5707    }
5708
5709    /// Submit an operator decision for `action_id`. Authorization is
5710    /// SERVER-SIDE: the edge forwards the `(provider, external_user_id)` it
5711    /// already authenticated (Slack HMAC / Telegram `secret_token`) and the
5712    /// control plane resolves it to an operator persona, refusing non-operators.
5713    /// `payload_hash` is the WYSIWYS hash the edge displayed; the control plane
5714    /// refuses an approval whose hash drifted from the live item.
5715    ///
5716    /// Idempotent: a duplicate Approve click on an already-decided item is
5717    /// [`DecideOutcome::Rejected`], never double-applied.
5718    ///
5719    /// # Errors
5720    /// Returns [`DialError::Connect`] for any transport/encoding error.
5721    pub async fn decide(
5722        &self,
5723        action_id: &str,
5724        approved: bool,
5725        provider: &str,
5726        external_user_id: &str,
5727        reason: &str,
5728        payload_hash: &str,
5729    ) -> Result<DecideResult, DialError> {
5730        let request = DecideRequest {
5731            action_id: action_id.to_owned(),
5732            approved,
5733            provider: provider.to_owned(),
5734            external_user_id: external_user_id.to_owned(),
5735            reason: reason.to_owned(),
5736            payload_hash: payload_hash.to_owned(),
5737            ..Default::default()
5738        };
5739        let reply = self
5740            .client
5741            .decide_with_options(request, traced_options())
5742            .await?
5743            .into_owned();
5744        let outcome = match reply.outcome.as_known() {
5745            Some(decide_reply::Outcome::APPLIED) => DecideOutcome::Applied,
5746            Some(decide_reply::Outcome::DENIED) => DecideOutcome::Denied,
5747            Some(decide_reply::Outcome::REJECTED) => DecideOutcome::Rejected,
5748            Some(decide_reply::Outcome::OUTCOME_UNSPECIFIED) | None => DecideOutcome::Unknown,
5749        };
5750        Ok(DecideResult {
5751            outcome,
5752            detail: reply.detail,
5753        })
5754    }
5755}
5756
5757/// The lifecycle state of one durable agent task.
5758///
5759/// The wire enum, mapped once here so an edge branches on a Rust enum rather
5760/// than on generated proto constants. `InputRequired` and `AuthRequired`
5761/// pause a task without finishing it; the four terminal states are final.
5762#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5763pub enum AgentTaskState {
5764    /// Accepted, not yet started. The only state a new task is created in.
5765    Submitted,
5766    /// The turn is running.
5767    Working,
5768    /// Paused while a person decides.
5769    InputRequired,
5770    /// Paused while the caller authenticates.
5771    AuthRequired,
5772    /// Finished successfully.
5773    Completed,
5774    /// Finished unsuccessfully.
5775    Failed,
5776    /// Cancelled by the caller.
5777    Canceled,
5778    /// The agent declined the task.
5779    Rejected,
5780}
5781
5782impl AgentTaskState {
5783    /// Reports whether the task has finished and can never move again.
5784    #[must_use]
5785    pub const fn is_terminal(self) -> bool {
5786        matches!(
5787            self,
5788            Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
5789        )
5790    }
5791}
5792
5793impl From<AgentTaskState> for WireAgentTaskState {
5794    fn from(value: AgentTaskState) -> Self {
5795        match value {
5796            AgentTaskState::Submitted => Self::AGENT_TASK_STATE_SUBMITTED,
5797            AgentTaskState::Working => Self::AGENT_TASK_STATE_WORKING,
5798            AgentTaskState::InputRequired => Self::AGENT_TASK_STATE_INPUT_REQUIRED,
5799            AgentTaskState::AuthRequired => Self::AGENT_TASK_STATE_AUTH_REQUIRED,
5800            AgentTaskState::Completed => Self::AGENT_TASK_STATE_COMPLETED,
5801            AgentTaskState::Failed => Self::AGENT_TASK_STATE_FAILED,
5802            AgentTaskState::Canceled => Self::AGENT_TASK_STATE_CANCELED,
5803            AgentTaskState::Rejected => Self::AGENT_TASK_STATE_REJECTED,
5804        }
5805    }
5806}
5807
5808/// One durable task, as [`TaskDialer`] read it back.
5809///
5810/// Every content field is opaque bytes: the control plane and the state plane
5811/// behind it store and return these frames without interpreting them, so the
5812/// caller's own surface format never becomes a second copy of itself there.
5813#[derive(Debug, Clone, PartialEq, Eq)]
5814pub struct AgentTaskRecord {
5815    /// Stable task identity.
5816    pub task_id: String,
5817    /// The conversation the task belongs to.
5818    pub context_id: String,
5819    /// Current lifecycle state.
5820    pub state: AgentTaskState,
5821    /// Opaque detail attached to the current status; empty when it carries none.
5822    pub status_detail: Vec<u8>,
5823    /// Opaque output frames.
5824    pub artifacts: Vec<Vec<u8>>,
5825    /// Opaque history frames, in order.
5826    pub history: Vec<Vec<u8>>,
5827    /// Opaque free-form metadata.
5828    pub metadata: Vec<u8>,
5829    /// When the task was created, in milliseconds since the Unix epoch.
5830    pub created_at_ms: u64,
5831    /// When the most recent accepted transition landed.
5832    pub updated_at_ms: u64,
5833}
5834
5835impl TryFrom<WireAgentTask> for AgentTaskRecord {
5836    type Error = DialError;
5837
5838    /// Names every field explicitly rather than spreading a default, so a
5839    /// field added to either side fails to compile instead of arriving zeroed
5840    /// (`#1241`/`#1238`).
5841    fn try_from(value: WireAgentTask) -> Result<Self, Self::Error> {
5842        let WireAgentTask {
5843            task_id,
5844            context_id,
5845            state,
5846            status_detail,
5847            artifacts,
5848            history,
5849            metadata,
5850            created_at_ms,
5851            updated_at_ms,
5852            __buffa_unknown_fields: _,
5853        } = value;
5854        let state = match state.as_known() {
5855            Some(WireAgentTaskState::AGENT_TASK_STATE_SUBMITTED) => AgentTaskState::Submitted,
5856            Some(WireAgentTaskState::AGENT_TASK_STATE_WORKING) => AgentTaskState::Working,
5857            Some(WireAgentTaskState::AGENT_TASK_STATE_INPUT_REQUIRED) => {
5858                AgentTaskState::InputRequired
5859            }
5860            Some(WireAgentTaskState::AGENT_TASK_STATE_AUTH_REQUIRED) => {
5861                AgentTaskState::AuthRequired
5862            }
5863            Some(WireAgentTaskState::AGENT_TASK_STATE_COMPLETED) => AgentTaskState::Completed,
5864            Some(WireAgentTaskState::AGENT_TASK_STATE_FAILED) => AgentTaskState::Failed,
5865            Some(WireAgentTaskState::AGENT_TASK_STATE_CANCELED) => AgentTaskState::Canceled,
5866            Some(WireAgentTaskState::AGENT_TASK_STATE_REJECTED) => AgentTaskState::Rejected,
5867            Some(WireAgentTaskState::AGENT_TASK_STATE_UNSPECIFIED) | None => {
5868                return Err(DialError::Connect(connectrpc::ConnectError::internal(
5869                    "the task record names no lifecycle state",
5870                )));
5871            }
5872        };
5873        Ok(Self {
5874            task_id,
5875            context_id,
5876            state,
5877            status_detail,
5878            artifacts,
5879            history,
5880            metadata,
5881            created_at_ms,
5882            updated_at_ms,
5883        })
5884    }
5885}
5886
5887/// The successor a [`TaskDialer::transition_task`] call asks for.
5888///
5889/// Status detail, artifacts, and metadata replace what the record carries;
5890/// history is extended by [`Self::appended_history`] and never rewritten.
5891#[derive(Debug, Clone, Default, PartialEq, Eq)]
5892pub struct AgentTaskTransition {
5893    /// Opaque detail for the new status; empty clears it.
5894    pub status_detail: Vec<u8>,
5895    /// Opaque output frames the task carries after this transition.
5896    pub artifacts: Vec<Vec<u8>>,
5897    /// Opaque metadata the task carries after this transition.
5898    pub metadata: Vec<u8>,
5899    /// Opaque history frames this transition appends, in order.
5900    pub appended_history: Vec<Vec<u8>>,
5901}
5902
5903/// Exact State-issued ownership for one task dispatch.
5904///
5905/// The dispatch, worker, and attempt identities bind the numeric fence to one
5906/// authenticated execution. A caller echoes the whole value on every later
5907/// update; a raw fence is never sufficient.
5908#[derive(Debug, Clone, PartialEq, Eq)]
5909pub struct AgentTaskOwnership {
5910    /// Stable State dispatch identity of the source event.
5911    pub dispatch_id: String,
5912    /// Stable identity of the edge process handling this delivery.
5913    pub worker_instance: String,
5914    /// Stable identity of this one execution attempt.
5915    pub attempt_id: String,
5916    /// State-wide fencing token issued to the attempt.
5917    pub fence: u64,
5918    /// Server-derived digest of the history attached by `ClaimTask`.
5919    pub claim_digest: Vec<u8>,
5920}
5921
5922impl From<&AgentTaskOwnership> for WireAgentTaskOwnership {
5923    fn from(value: &AgentTaskOwnership) -> Self {
5924        Self {
5925            dispatch_id: value.dispatch_id.clone(),
5926            worker_instance: value.worker_instance.clone(),
5927            attempt_id: value.attempt_id.clone(),
5928            fence: value.fence,
5929            claim_digest: value.claim_digest.clone(),
5930            __buffa_unknown_fields: buffa::UnknownFields::default(),
5931        }
5932    }
5933}
5934
5935impl From<WireAgentTaskOwnership> for AgentTaskOwnership {
5936    fn from(value: WireAgentTaskOwnership) -> Self {
5937        Self {
5938            dispatch_id: value.dispatch_id,
5939            worker_instance: value.worker_instance,
5940            attempt_id: value.attempt_id,
5941            fence: value.fence,
5942            claim_digest: value.claim_digest,
5943        }
5944    }
5945}
5946
5947/// One page of a context's tasks, as [`TaskDialer::list_tasks`] read it back.
5948#[derive(Debug, Clone, Default, PartialEq, Eq)]
5949pub struct AgentTaskPage {
5950    /// The page's tasks, in identity order.
5951    pub tasks: Vec<AgentTaskRecord>,
5952    /// The identity to pass back as `after` for the next page; `None` when
5953    /// this page reached the end of the context's index.
5954    pub next_after: Option<String>,
5955}
5956
5957/// Reusable handle for the control plane's `AgentTaskService`.
5958///
5959/// The durable task-record path a protocol edge drives its lifecycle over.
5960/// Shares the `AgentService` endpoint — both are served on one Connect port —
5961/// so it is built from the same address.
5962///
5963/// Bearer-only, exactly like [`ApprovalDialer::with_bearer`]: these calls send
5964/// no `AgentStart`, so no signed attribution envelope rides them.
5965#[derive(Clone)]
5966pub struct TaskDialer {
5967    client: Arc<AgentTaskServiceClient<HttpClient>>,
5968}
5969
5970const fn is_ambiguous_task_error(error: &connectrpc::ConnectError) -> bool {
5971    matches!(
5972        error.code,
5973        connectrpc::ErrorCode::Unavailable
5974            | connectrpc::ErrorCode::DeadlineExceeded
5975            | connectrpc::ErrorCode::ResourceExhausted
5976            | connectrpc::ErrorCode::Aborted
5977    )
5978}
5979
5980impl TaskDialer {
5981    /// Build a dialer pointed at `addr` (expects `http://host:port`).
5982    ///
5983    /// # Errors
5984    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI, or
5985    /// [`DialError::Tls`] if an `https` endpoint's TLS setup fails.
5986    pub fn new(addr: &str) -> Result<Self, DialError> {
5987        Ok(Self {
5988            client: build_control_client(addr, None, AgentTaskServiceClient::new)?,
5989        })
5990    }
5991
5992    /// Build a dialer pointed at `addr`, authenticated with `bearer`.
5993    ///
5994    /// # Errors
5995    /// Returns [`DialError::InvalidAddress`] if `addr` isn't a valid URI,
5996    /// [`DialError::Tls`] if an `https` endpoint's TLS setup fails, or
5997    /// [`DialError::InvalidBearer`] if `bearer` can't be encoded as an HTTP
5998    /// header value.
5999    pub fn with_bearer(addr: &str, bearer: &str) -> Result<Self, DialError> {
6000        Ok(Self {
6001            client: build_control_client(addr, Some(bearer), AgentTaskServiceClient::new)?,
6002        })
6003    }
6004
6005    /// Mint a task in [`AgentTaskState::Submitted`] and index it under
6006    /// `context_id`, carrying `history` as its opening frames.
6007    ///
6008    /// # Errors
6009    /// Returns [`DialError::Connect`] for any transport or encoding error,
6010    /// including the control plane's refusal of an identity that already
6011    /// exists (`already_exists`).
6012    pub async fn create_task(
6013        &self,
6014        task_id: &str,
6015        context_id: &str,
6016        history: Vec<Vec<u8>>,
6017    ) -> Result<AgentTaskRecord, DialError> {
6018        let request = CreateAgentTaskRequest {
6019            task_id: task_id.to_owned(),
6020            context_id: context_id.to_owned(),
6021            history,
6022            ..Default::default()
6023        };
6024        let reply = self
6025            .client
6026            .create_task_with_options(request, traced_options())
6027            .await?
6028            .into_owned();
6029        Self::require_task(reply.task.into_option())
6030    }
6031
6032    /// Move a live task to `state`, carrying `successor`'s frames.
6033    ///
6034    /// # Errors
6035    /// Returns [`DialError::Connect`] for any transport or encoding error,
6036    /// including `not_found` for an unknown task and `failed_precondition`
6037    /// for a task that already finished.
6038    pub async fn transition_task(
6039        &self,
6040        task_id: &str,
6041        state: AgentTaskState,
6042        successor: AgentTaskTransition,
6043        ownership: &AgentTaskOwnership,
6044    ) -> Result<AgentTaskRecord, DialError> {
6045        let AgentTaskTransition {
6046            status_detail,
6047            artifacts,
6048            metadata,
6049            appended_history,
6050        } = successor;
6051        let request = TransitionAgentTaskRequest {
6052            task_id: task_id.to_owned(),
6053            state: WireAgentTaskState::from(state).into(),
6054            status_detail,
6055            artifacts,
6056            metadata,
6057            appended_history,
6058            ownership: buffa::MessageField::some(WireAgentTaskOwnership::from(ownership)),
6059            operation_id: uuid::Uuid::now_v7().to_string(),
6060            ..Default::default()
6061        };
6062        let first = self
6063            .client
6064            .transition_task_with_options(request.clone(), traced_options())
6065            .await;
6066        let reply = match first {
6067            Ok(reply) => reply,
6068            Err(error) if is_ambiguous_task_error(&error) => {
6069                self.client
6070                    .transition_task_with_options(request, traced_options())
6071                    .await?
6072            }
6073            Err(error) => return Err(DialError::Connect(error)),
6074        }
6075        .into_owned();
6076        Self::require_task(reply.task.into_option())
6077    }
6078
6079    /// Claims one task for a durable source dispatch before its turn runs.
6080    ///
6081    /// `worker_instance` is stable for one edge process and `attempt_id` for
6082    /// one delivery attempt. Exact retries reuse both; a competing execution
6083    /// must use a different attempt.
6084    ///
6085    /// # Errors
6086    /// Returns [`DialError::Connect`] for transport failures, malformed
6087    /// ownership replies, or a live competing claim.
6088    pub async fn claim_task(
6089        &self,
6090        task_id: &str,
6091        dispatch_id: &str,
6092        worker_instance: &str,
6093        attempt_id: &str,
6094        appended_history: Vec<Vec<u8>>,
6095    ) -> Result<(AgentTaskRecord, AgentTaskOwnership), DialError> {
6096        let request = ClaimAgentTaskRequest {
6097            task_id: task_id.to_owned(),
6098            dispatch_id: dispatch_id.to_owned(),
6099            worker_instance: worker_instance.to_owned(),
6100            attempt_id: attempt_id.to_owned(),
6101            appended_history,
6102            ..Default::default()
6103        };
6104        // A response can disappear after State commits the claim. Retry once
6105        // with the byte-for-byte same ownership identity; TaskService then
6106        // replays the receipt instead of consuming another grant. A retry
6107        // constructed by the outer edge would mint a new delivery attempt and
6108        // therefore cannot recover this ambiguous outcome.
6109        let first = self
6110            .client
6111            .claim_task_with_options(request.clone(), traced_options())
6112            .await;
6113        let reply = match first {
6114            Ok(reply) => reply,
6115            Err(error) if is_ambiguous_task_error(&error) => {
6116                self.client
6117                    .claim_task_with_options(request, traced_options())
6118                    .await?
6119            }
6120            Err(error) => return Err(DialError::Connect(error)),
6121        }
6122        .into_owned();
6123        let task = Self::require_task(reply.task.into_option())?;
6124        let ownership = reply.ownership.into_option().ok_or_else(|| {
6125            DialError::Connect(connectrpc::ConnectError::internal(
6126                "a successful task claim returned no ownership capability",
6127            ))
6128        })?;
6129        Ok((task, AgentTaskOwnership::from(ownership)))
6130    }
6131
6132    /// Renews one exact task ownership capability without changing its fence.
6133    ///
6134    /// # Errors
6135    /// Returns [`DialError::Connect`] for transport failures or a stale
6136    /// capability. Ambiguous transport outcomes replay the same ordinal.
6137    pub async fn renew_task_claim(
6138        &self,
6139        task_id: &str,
6140        ownership: &AgentTaskOwnership,
6141        ordinal: u64,
6142    ) -> Result<(), DialError> {
6143        let request = RenewAgentTaskClaimRequest {
6144            task_id: task_id.to_owned(),
6145            ownership: buffa::MessageField::some(WireAgentTaskOwnership::from(ownership)),
6146            ordinal,
6147            ..Default::default()
6148        };
6149        let first = self
6150            .client
6151            .renew_task_claim_with_options(request.clone(), traced_options())
6152            .await;
6153        match first {
6154            Ok(_) => Ok(()),
6155            Err(error) if is_ambiguous_task_error(&error) => self
6156                .client
6157                .renew_task_claim_with_options(request, traced_options())
6158                .await
6159                .map(|_| ())
6160                .map_err(DialError::Connect),
6161            Err(error) => Err(DialError::Connect(error)),
6162        }
6163    }
6164
6165    /// Cancel a live task.
6166    ///
6167    /// # Errors
6168    /// Returns [`DialError::Connect`] for any transport or encoding error,
6169    /// including `not_found` for an unknown task and `failed_precondition`
6170    /// for a task that already finished — cancellation's refusal is part of
6171    /// its contract, never a quiet success.
6172    pub async fn cancel_task(&self, task_id: &str) -> Result<AgentTaskRecord, DialError> {
6173        let request = CancelAgentTaskRequest {
6174            task_id: task_id.to_owned(),
6175            ..Default::default()
6176        };
6177        let reply = self
6178            .client
6179            .cancel_task_with_options(request, traced_options())
6180            .await?
6181            .into_owned();
6182        Self::require_task(reply.task.into_option())
6183    }
6184
6185    /// Read one task by identity. `None` means no task exists under it — a
6186    /// plain absence, not a failure.
6187    ///
6188    /// # Errors
6189    /// Returns [`DialError::Connect`] for any transport or encoding error.
6190    pub async fn get_task(&self, task_id: &str) -> Result<Option<AgentTaskRecord>, DialError> {
6191        let request = GetAgentTaskRequest {
6192            task_id: task_id.to_owned(),
6193            ..Default::default()
6194        };
6195        let reply = self
6196            .client
6197            .get_task_with_options(request, traced_options())
6198            .await?
6199            .into_owned();
6200        reply
6201            .task
6202            .into_option()
6203            .map(AgentTaskRecord::try_from)
6204            .transpose()
6205    }
6206
6207    /// Read one bounded page of `context_id`'s tasks, resuming after `after`
6208    /// when a previous page returned one.
6209    ///
6210    /// # Errors
6211    /// Returns [`DialError::Connect`] for any transport or encoding error,
6212    /// including `invalid_argument` for a page size above the server maximum.
6213    pub async fn list_tasks(
6214        &self,
6215        context_id: &str,
6216        page_size: u32,
6217        after: Option<&str>,
6218    ) -> Result<AgentTaskPage, DialError> {
6219        let request = ListAgentTasksRequest {
6220            context_id: context_id.to_owned(),
6221            page_size,
6222            after: after.unwrap_or_default().to_owned(),
6223            ..Default::default()
6224        };
6225        let reply = self
6226            .client
6227            .list_tasks_with_options(request, traced_options())
6228            .await?
6229            .into_owned();
6230        let tasks = reply
6231            .tasks
6232            .into_iter()
6233            .map(AgentTaskRecord::try_from)
6234            .collect::<Result<Vec<_>, _>>()?;
6235        Ok(AgentTaskPage {
6236            tasks,
6237            next_after: (!reply.next_after.is_empty()).then_some(reply.next_after),
6238        })
6239    }
6240
6241    /// Unwrap the task every mutating reply carries, failing closed rather
6242    /// than inventing an empty record when one is somehow absent.
6243    fn require_task(task: Option<WireAgentTask>) -> Result<AgentTaskRecord, DialError> {
6244        task.ok_or_else(|| {
6245            DialError::Connect(connectrpc::ConnectError::internal(
6246                "the task operation returned no task record",
6247            ))
6248        })
6249        .and_then(AgentTaskRecord::try_from)
6250    }
6251}
6252
6253#[cfg(test)]
6254mod tests {
6255    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
6256    use super::*;
6257    use polyc_proto::proto::polychrome::agent::v1::{
6258        Content, FunctionCallContent, TextContent, ThoughtContent, ThoughtSummaryContent,
6259        ToolCallContent, ToolResultContent, thought_summary_content,
6260    };
6261
6262    /// Invariant: absent, empty, and present wrapped secrets each read the
6263    /// way the edges depend on — no secret means `None`, an empty-string
6264    /// placeholder means `None` (unconfigured, not "configured as blank"),
6265    /// and a real value exposes back out unchanged.
6266    #[test]
6267    fn expose_nonempty_treats_empty_as_unset() {
6268        assert_eq!(expose_nonempty(None), None);
6269
6270        let empty = Sensitive::new(String::new());
6271        assert_eq!(expose_nonempty(Some(&empty)), None);
6272
6273        let value = Sensitive::new("real-secret".to_owned());
6274        assert_eq!(expose_nonempty(Some(&value)), Some("real-secret"));
6275    }
6276
6277    #[test]
6278    fn ops_copy_is_shared_and_jargon_free() {
6279        // The upgrade ask is worded through the shared update-copy helper: it
6280        // names the version, carries the exact "Apply now" verb (a cluster roll
6281        // is a warm, in-place restart), and reads identically to the CLI and
6282        // dashboard surfaces.
6283        let up = OpsAction::UpgradeTo {
6284            version: "1.2.3".to_owned(),
6285        };
6286        let summary = up.summary();
6287        assert!(
6288            summary.contains("1.2.3"),
6289            "summary names the version: {summary}"
6290        );
6291        assert!(
6292            summary.contains("restarts the service"),
6293            "warm upgrade summary is honest about the restart: {summary}"
6294        );
6295        assert_eq!(
6296            up.approve_verb(),
6297            Some(polyc_runtime::update_copy::APPLY_NOW),
6298            "an in-place upgrade carries the shared Apply now verb"
6299        );
6300        assert!(
6301            !summary.to_lowercase().contains("operator"),
6302            "no banned jargon in the upgrade ask: {summary}"
6303        );
6304        assert_eq!(OpsAction::Unknown.approve_verb(), None);
6305        let unknown = OpsAction::Unknown.summary();
6306        assert!(!unknown.to_lowercase().contains("control-plane"));
6307        assert!(!unknown.is_empty());
6308        // The shared heading carries no banned jargon.
6309        assert!(!OPS_PROMPT_HEADING.to_lowercase().contains("operator"));
6310
6311        // Metric labels are stable and distinct.
6312        assert_eq!(DecideOutcome::Applied.metric_label(), "applied");
6313        assert_eq!(DecideOutcome::Denied.metric_label(), "denied");
6314        assert_eq!(DecideOutcome::Rejected.metric_label(), "rejected");
6315        assert_eq!(DecideOutcome::Unknown.metric_label(), "unknown");
6316
6317        // The decided line surfaces each verdict, the decider, and reject detail,
6318        // and never says "please".
6319        let applied = DecideResult {
6320            outcome: DecideOutcome::Applied,
6321            detail: String::new(),
6322        }
6323        .decided_line("Chris");
6324        assert!(applied.contains("Approved") && applied.contains("Chris"));
6325        let rejected = DecideResult {
6326            outcome: DecideOutcome::Rejected,
6327            detail: "not authorized to approve".to_owned(),
6328        }
6329        .decided_line("Chris");
6330        assert!(rejected.contains("Rejected") && rejected.contains("not authorized to approve"));
6331        let unknown = DecideResult {
6332            outcome: DecideOutcome::Unknown,
6333            detail: String::new(),
6334        }
6335        .decided_line("Chris");
6336        assert!(!unknown.to_lowercase().contains("please"));
6337    }
6338
6339    #[test]
6340    fn approval_choice_flags() {
6341        assert!(ApprovalChoice::Approve.approved());
6342        assert!(!ApprovalChoice::Approve.approved_for_session());
6343        assert!(!ApprovalChoice::Approve.is_abort());
6344        assert!(ApprovalChoice::ApproveForSession.approved());
6345        assert!(ApprovalChoice::ApproveForSession.approved_for_session());
6346        // Deny and Abort both decline; only Abort stops the turn.
6347        assert!(!ApprovalChoice::Deny.approved());
6348        assert!(!ApprovalChoice::Deny.is_abort());
6349        assert!(!ApprovalChoice::Abort.approved());
6350        assert!(ApprovalChoice::Abort.is_abort());
6351    }
6352
6353    /// `#743`: the completed-card line names the tool, the decider, and the
6354    /// outcome — the runtime's report of what actually happened, distinct
6355    /// from `approval_decided_text`'s earlier "running…" line.
6356    #[test]
6357    fn approval_completed_text_reports_the_runtime_outcome() {
6358        let done = approval_completed_text("Remove @vitor's admin role", "Chris", true);
6359        assert!(done.contains("Chris"), "names the decider: {done}");
6360        assert!(
6361            done.contains("Remove @vitor's admin role"),
6362            "names the tool label: {done}"
6363        );
6364        assert!(done.contains("done"), "a success reads as done: {done}");
6365
6366        let failed = approval_completed_text("Remove @vitor's admin role", "Chris", false);
6367        assert_ne!(
6368            done, failed,
6369            "success and failure must not read identically"
6370        );
6371        assert!(
6372            failed.contains("error"),
6373            "a failed run must say so, not claim success: {failed}"
6374        );
6375
6376        for copy in [&done, &failed] {
6377            let lower = copy.to_lowercase();
6378            for banned in ["please", "sorry", "unfortunately", "operator"] {
6379                assert!(
6380                    !lower.contains(banned),
6381                    "banned word {banned:?} in {copy:?}"
6382                );
6383            }
6384        }
6385    }
6386
6387    #[test]
6388    fn compacted_summarized_maps_with_preview() {
6389        let c = ContextCompacted {
6390            reason: buffa::EnumValue::from(
6391                WireCompactionReason::COMPACTION_REASON_SUMMARIZED as i32,
6392            ),
6393            summarized_messages: 12,
6394            summary_preview: "earlier work: fixed bug X".to_owned(),
6395            ..Default::default()
6396        };
6397        assert_eq!(
6398            event_from_compacted(c),
6399            TurnEvent::ContextCompacted {
6400                reason: CompactionReason::Summarized,
6401                summarized_messages: 12,
6402                summary_preview: "earlier work: fixed bug X".to_owned(),
6403            }
6404        );
6405    }
6406
6407    #[test]
6408    fn compacted_truncated_maps_without_preview() {
6409        let c = ContextCompacted {
6410            reason: buffa::EnumValue::from(
6411                WireCompactionReason::COMPACTION_REASON_TRUNCATED as i32,
6412            ),
6413            ..Default::default()
6414        };
6415        assert_eq!(
6416            event_from_compacted(c),
6417            TurnEvent::ContextCompacted {
6418                reason: CompactionReason::Truncated,
6419                summarized_messages: 0,
6420                summary_preview: String::new(),
6421            }
6422        );
6423    }
6424
6425    #[test]
6426    fn compacted_unknown_reason_falls_back_to_truncated() {
6427        // An unspecified/future wire reason must not panic a live surface; it
6428        // maps to the quieter, no-preview rendering.
6429        let c = ContextCompacted {
6430            reason: buffa::EnumValue::from(
6431                WireCompactionReason::COMPACTION_REASON_UNSPECIFIED as i32,
6432            ),
6433            ..Default::default()
6434        };
6435        assert!(matches!(
6436            event_from_compacted(c),
6437            TurnEvent::ContextCompacted {
6438                reason: CompactionReason::Truncated,
6439                ..
6440            }
6441        ));
6442    }
6443
6444    #[test]
6445    fn dial_error_retryable_classification() {
6446        use connectrpc::ErrorCode;
6447        // Transient transport conditions → retryable (ask for redelivery).
6448        for code in [
6449            ErrorCode::Unavailable,
6450            ErrorCode::DeadlineExceeded,
6451            ErrorCode::ResourceExhausted,
6452            ErrorCode::Aborted,
6453        ] {
6454            let err = DialError::Connect(connectrpc::ConnectError::new(code, "x"));
6455            assert_eq!(err.code(), Some(code));
6456            assert!(err.is_retryable(), "{code:?} should be retryable");
6457        }
6458        // Terminal codes → NOT retryable (redelivery would loop forever).
6459        for code in [
6460            ErrorCode::InvalidArgument,
6461            ErrorCode::Unauthenticated,
6462            ErrorCode::NotFound,
6463            ErrorCode::PermissionDenied,
6464            ErrorCode::Internal,
6465        ] {
6466            let err = DialError::Connect(connectrpc::ConnectError::new(code, "x"));
6467            assert_eq!(err.code(), Some(code));
6468            assert!(!err.is_retryable(), "{code:?} should not be retryable");
6469        }
6470        // Local failures carry no Connect code and are never retryable.
6471        let bad_addr = DialError::InvalidAddress {
6472            addr: "http://a b".to_owned(),
6473            source: "http://a b".parse::<http::Uri>().unwrap_err(),
6474        };
6475        assert_eq!(bad_addr.code(), None);
6476        assert!(!bad_addr.is_retryable());
6477        let tls = DialError::Tls("no provider".to_owned());
6478        assert_eq!(tls.code(), None);
6479        assert!(!tls.is_retryable());
6480    }
6481
6482    #[test]
6483    fn deadline_exceeded_is_the_only_code_that_reads_as_a_routine_recycle() {
6484        let deadline = DialError::Connect(connectrpc::ConnectError::deadline_exceeded("idle"));
6485        assert!(deadline.is_deadline_exceeded());
6486
6487        let unavailable = DialError::Connect(connectrpc::ConnectError::unavailable("gone"));
6488        assert!(
6489            !unavailable.is_deadline_exceeded(),
6490            "a genuinely dropped stream must still be reported as a failure"
6491        );
6492
6493        let local = DialError::Tls("no provider".to_owned());
6494        assert!(
6495            !local.is_deadline_exceeded(),
6496            "a local setup failure has no Connect code and is never a routine recycle"
6497        );
6498    }
6499
6500    fn text(s: &str) -> Option<content::Type> {
6501        Some(content::Type::Text(Box::new(TextContent {
6502            text: s.to_owned(),
6503            ..Default::default()
6504        })))
6505    }
6506
6507    fn tool_call(id: &str, name: &str) -> Option<content::Type> {
6508        Some(content::Type::ToolCall(Box::new(ToolCallContent {
6509            id: id.to_owned(),
6510            r#type: Some(tool_call_content::Type::FunctionCall(Box::new(
6511                FunctionCallContent {
6512                    name: name.to_owned(),
6513                    ..Default::default()
6514                },
6515            ))),
6516            ..Default::default()
6517        })))
6518    }
6519
6520    fn tool_result(call_id: &str) -> Option<content::Type> {
6521        Some(content::Type::ToolResult(Box::new(ToolResultContent {
6522            call_id: call_id.to_owned(),
6523            ..Default::default()
6524        })))
6525    }
6526
6527    fn thought(summary: &str) -> Option<content::Type> {
6528        Some(content::Type::Thought(Box::new(ThoughtContent {
6529            summary: vec![ThoughtSummaryContent {
6530                r#type: Some(thought_summary_content::Type::Text(Box::new(TextContent {
6531                    text: summary.to_owned(),
6532                    ..Default::default()
6533                }))),
6534                ..Default::default()
6535            }],
6536            ..Default::default()
6537        })))
6538    }
6539
6540    #[test]
6541    fn renders_text_verbatim() {
6542        assert_eq!(render_content(text("hi")), Some("hi".to_owned()));
6543    }
6544
6545    #[test]
6546    fn renders_tool_call_with_name_and_id() {
6547        assert_eq!(
6548            render_content(tool_call("call_42", "search")),
6549            Some("[tool_call:search call_42]".to_owned())
6550        );
6551    }
6552
6553    #[test]
6554    fn renders_tool_call_without_function_name() {
6555        assert_eq!(
6556            render_content(Some(content::Type::ToolCall(Box::new(ToolCallContent {
6557                id: "call_bare".to_owned(),
6558                r#type: None,
6559                ..Default::default()
6560            })))),
6561            Some("[tool_call:call_bare]".to_owned())
6562        );
6563    }
6564
6565    #[test]
6566    fn renders_tool_result_by_call_id() {
6567        assert_eq!(
6568            render_content(tool_result("call_42")),
6569            Some("[tool_result:call_42]".to_owned())
6570        );
6571    }
6572
6573    #[test]
6574    fn reasoning_is_never_the_reply() {
6575        // Reasoning must NOT render into the buffered reply (raw chain-of-thought
6576        // as the answer). It is shown via the TUI's separate transcript path.
6577        assert_eq!(render_content(thought("considering options")), None);
6578    }
6579
6580    #[test]
6581    fn thought_only_turn_yields_empty_reply() {
6582        // A turn that produced only reasoning (no answer text, no tool calls)
6583        // must come back empty, not with the chain-of-thought as the reply.
6584        assert_eq!(aggregate(vec![thought("secret reasoning")]), "");
6585    }
6586
6587    #[test]
6588    fn empty_text_skipped() {
6589        assert_eq!(render_content(text("")), None);
6590    }
6591
6592    #[test]
6593    fn unknown_variant_skipped() {
6594        // No content::Type set at all.
6595        assert_eq!(render_content(None), None);
6596    }
6597
6598    // Helper used by aggregation tests: feed the same rendering loop
6599    // run_turn uses, but driven from a vector of fake content blocks rather
6600    // than a live connectrpc stream.
6601    fn aggregate(blocks: Vec<Option<content::Type>>) -> String {
6602        let mut parts: Vec<String> = Vec::new();
6603        for b in blocks {
6604            if let Some(s) = render_content(b) {
6605                parts.push(s);
6606            }
6607        }
6608        parts.join("\n")
6609    }
6610
6611    #[test]
6612    fn aggregate_pure_text_turn() {
6613        assert_eq!(
6614            aggregate(vec![text("hello"), text("world")]),
6615            "hello\nworld"
6616        );
6617    }
6618
6619    #[test]
6620    fn aggregate_tool_call_only_turn() {
6621        // A turn that ends in a tool call with no follow-up text used to
6622        // come back as "" and was silently dropped by the Slack handler.
6623        // Rendering a placeholder keeps the user informed.
6624        assert_eq!(
6625            aggregate(vec![tool_call("call_1", "lookup")]),
6626            "[tool_call:lookup call_1]"
6627        );
6628    }
6629
6630    #[test]
6631    fn aggregate_mixed_text_and_tool_call() {
6632        assert_eq!(
6633            aggregate(vec![text("thinking..."), tool_call("call_1", "search")]),
6634            "thinking...\n[tool_call:search call_1]"
6635        );
6636    }
6637
6638    /// `#743`: the buffered aggregation path must skip `internal_only`
6639    /// messages entirely — neither the assistant-text nor the scaffolding
6640    /// leg — mirroring `message_to_event`'s streaming-path rule.
6641    #[test]
6642    fn buffered_aggregation_skips_internal_only_messages() {
6643        let mut withheld = message("model", text("pending your approval"));
6644        withheld.internal_only = true;
6645        let mut text_parts = Vec::new();
6646        let mut scaffolding = Vec::new();
6647        aggregate_output_message(withheld, &mut text_parts, &mut scaffolding);
6648        assert!(
6649            text_parts.is_empty(),
6650            "withheld text must not become the reply"
6651        );
6652        assert!(
6653            scaffolding.is_empty(),
6654            "withheld text must not fall back to scaffolding either"
6655        );
6656    }
6657
6658    #[test]
6659    fn buffered_aggregation_keeps_visible_assistant_text() {
6660        // Regression: a normal (non-internal_only) assistant message must
6661        // still aggregate exactly as before.
6662        let mut text_parts = Vec::new();
6663        let mut scaffolding = Vec::new();
6664        aggregate_output_message(
6665            message("model", text("the answer")),
6666            &mut text_parts,
6667            &mut scaffolding,
6668        );
6669        assert_eq!(text_parts, vec!["the answer".to_owned()]);
6670        assert!(scaffolding.is_empty());
6671    }
6672
6673    /// `#519` follow-up: a wallet-link-needed turn with NO preceding real
6674    /// content gets exactly the deterministic shared-copy prompt, byte-
6675    /// identical to what `polyc_proto::wallet_link_prompt` produces — not
6676    /// something a caller must special-case, since `finalize_buffered_reply`
6677    /// decides it centrally for every buffered edge (Discord, email,
6678    /// trigger).
6679    #[test]
6680    fn finalize_buffered_reply_wallet_link_prompt_wins_with_url() {
6681        let url = "https://polychrome.example/link/abc";
6682        let reply = finalize_buffered_reply(
6683            &[],
6684            &[],
6685            WalletLinkPrompt::Present {
6686                link_url: Some(url.to_owned()),
6687                renewal: false,
6688                requested: false,
6689            },
6690            None,
6691            None,
6692        );
6693        assert_eq!(reply, polyc_proto::wallet_link_prompt(Some(url), false));
6694        assert!(
6695            reply.contains(url),
6696            "the deterministic prompt carries the link: {reply}"
6697        );
6698    }
6699
6700    #[test]
6701    fn finalize_buffered_reply_wallet_link_prompt_wins_without_url() {
6702        let reply = finalize_buffered_reply(
6703            &[],
6704            &[],
6705            WalletLinkPrompt::Present {
6706                link_url: None,
6707                renewal: false,
6708                requested: false,
6709            },
6710            None,
6711            None,
6712        );
6713        assert_eq!(reply, polyc_proto::wallet_link_prompt(None, false));
6714    }
6715
6716    /// The originally reported bug this PR fixes: a directly-requested
6717    /// `wallet_link` call must get the requested-branch prompt, distinct
6718    /// from the payment-interrupt one, on a buffered edge.
6719    #[test]
6720    fn finalize_buffered_reply_wallet_link_prompt_requested_wins() {
6721        let url = "https://polychrome.example/link/abc";
6722        let reply = finalize_buffered_reply(
6723            &[],
6724            &[],
6725            WalletLinkPrompt::Present {
6726                link_url: Some(url.to_owned()),
6727                renewal: false,
6728                requested: true,
6729            },
6730            None,
6731            None,
6732        );
6733        assert_eq!(reply, polyc_proto::wallet_link_requested_prompt(url));
6734        assert!(
6735            reply.contains(url),
6736            "the requested-link prompt carries the link: {reply}"
6737        );
6738        assert_ne!(
6739            reply,
6740            polyc_proto::wallet_link_prompt(Some(url), false),
6741            "the requested branch must read differently from the payment-interrupt one"
6742        );
6743    }
6744
6745    /// `#2122`: the renewal branch — `finalize_buffered_reply` must produce
6746    /// the renewal wording, not the first-time one, and it must never read
6747    /// as first-time setup.
6748    #[test]
6749    fn finalize_buffered_reply_wallet_link_prompt_renewal_wins() {
6750        let url = "https://polychrome.example/link/abc";
6751        let reply = finalize_buffered_reply(
6752            &[],
6753            &[],
6754            WalletLinkPrompt::Present {
6755                link_url: Some(url.to_owned()),
6756                renewal: true,
6757                requested: false,
6758            },
6759            None,
6760            None,
6761        );
6762        assert_eq!(reply, polyc_proto::wallet_link_prompt(Some(url), true));
6763        assert!(reply.to_lowercase().contains("expired"), "{reply}");
6764        assert!(
6765            !reply.to_lowercase().contains("no spending wallet set up"),
6766            "{reply}"
6767        );
6768    }
6769
6770    /// Third-review finding: `text_parts` cannot be trusted as filtered,
6771    /// already-genuine content — `run_turn_with` dials the same
6772    /// `AgentService.connect` RPC as the streaming API, and the control plane
6773    /// forwards the harness's live `TextDelta`s to every client before the
6774    /// harness's post-hoc `internal_only` classification ever runs (and skips
6775    /// re-sending the filtered terminal batch once any delta was forwarded).
6776    /// So `text_parts` can hold the model's own raw, unfiltered wallet-link
6777    /// narration exactly like Slack/Telegram's live accumulator — a prior
6778    /// round's belief that appending was safe here was wrong. A
6779    /// wallet-link-needed turn must fully replace `text_parts`, matching
6780    /// Slack's `interrupt_holdback_text` and Telegram's reply-override.
6781    #[test]
6782    fn finalize_buffered_reply_wallet_link_prompt_replaces_preceding_content() {
6783        let url = "https://polychrome.example/link/abc";
6784        let reply = finalize_buffered_reply(
6785            &["It looks like you'll need to link a wallet first.".to_owned()],
6786            &[],
6787            WalletLinkPrompt::Present {
6788                link_url: Some(url.to_owned()),
6789                renewal: false,
6790                requested: false,
6791            },
6792            None,
6793            None,
6794        );
6795        assert_eq!(
6796            reply,
6797            polyc_proto::wallet_link_prompt(Some(url), false),
6798            "preceding text_parts must not survive — it may be the model's own \
6799             unfiltered wallet-link narration, not genuine unrelated content: {reply}"
6800        );
6801    }
6802
6803    /// A turn with no wallet-link signal is unaffected — the same
6804    /// text-then-scaffolding fallback behavior as before this change.
6805    #[test]
6806    fn finalize_buffered_reply_without_wallet_link_prompt_is_unchanged() {
6807        assert_eq!(
6808            finalize_buffered_reply(
6809                &["the answer".to_owned()],
6810                &[],
6811                WalletLinkPrompt::None,
6812                None,
6813                None,
6814            ),
6815            "the answer"
6816        );
6817        assert_eq!(
6818            finalize_buffered_reply(
6819                &[],
6820                &["[tool_call:foo]".to_owned()],
6821                WalletLinkPrompt::None,
6822                None,
6823                None,
6824            ),
6825            "[tool_call:foo]"
6826        );
6827        assert_eq!(
6828            finalize_buffered_reply(&[], &[], WalletLinkPrompt::None, None, None),
6829            ""
6830        );
6831    }
6832
6833    /// Issue #1041/#1159 sibling of
6834    /// `finalize_buffered_reply_wallet_link_prompt_wins_with_url`: a
6835    /// `wallet_update_limit` mint with no preceding content gets exactly the
6836    /// deterministic shared-copy prompt, carrying both the link and the
6837    /// requested cap.
6838    #[test]
6839    fn finalize_buffered_reply_wallet_update_prompt_wins() {
6840        let url = "https://polychrome.example/update/xyz";
6841        let reply =
6842            finalize_buffered_reply(&[], &[], WalletLinkPrompt::None, Some((url, "20")), None);
6843        assert_eq!(reply, polyc_proto::wallet_update_prompt(url, "20"));
6844        assert!(reply.contains(url), "{reply}");
6845        assert!(reply.contains("20"), "{reply}");
6846    }
6847
6848    /// Issue #1042/#1156 sibling of the same: a `unlink_self` (wallet
6849    /// target) hard-revoke mint with no preceding content gets exactly the
6850    /// deterministic shared-copy prompt.
6851    #[test]
6852    fn finalize_buffered_reply_wallet_revoke_prompt_wins() {
6853        let url = "https://polychrome.example/revoke/xyz";
6854        let reply = finalize_buffered_reply(&[], &[], WalletLinkPrompt::None, None, Some(url));
6855        assert_eq!(reply, polyc_proto::wallet_revoke_prompt(url));
6856        assert!(reply.contains(url), "{reply}");
6857    }
6858
6859    // --- streaming mapping (message_to_event) ---
6860
6861    fn message(role: &str, ty: Option<content::Type>) -> Message {
6862        Message {
6863            role: role.to_owned(),
6864            content: buffa::MessageField::some(Content {
6865                r#type: ty,
6866                ..Default::default()
6867            }),
6868            ..Default::default()
6869        }
6870    }
6871
6872    #[test]
6873    fn assistant_text_becomes_text_delta() {
6874        assert_eq!(
6875            message_to_event(message("assistant", text("hello"))),
6876            Some(TurnEvent::TextDelta("hello".to_owned()))
6877        );
6878    }
6879
6880    #[test]
6881    fn model_role_also_counts_as_assistant() {
6882        assert_eq!(
6883            message_to_event(message("model", text("hi"))),
6884            Some(TurnEvent::TextDelta("hi".to_owned()))
6885        );
6886    }
6887
6888    #[test]
6889    fn tool_role_text_is_skipped() {
6890        // A tool-result echo carried as text must not surface as answer text.
6891        assert_eq!(message_to_event(message("tool", text("result blob"))), None);
6892    }
6893
6894    #[test]
6895    fn empty_assistant_text_is_skipped() {
6896        assert_eq!(message_to_event(message("assistant", text(""))), None);
6897    }
6898
6899    /// `#743`: an `internal_only` message — a paused turn's withheld model
6900    /// text, or an approver/policy note — must never surface as a streamed
6901    /// event, even though it carries otherwise-eligible assistant text.
6902    #[test]
6903    fn internal_only_assistant_text_is_skipped() {
6904        let mut msg = message("assistant", text("pending your approval"));
6905        msg.internal_only = true;
6906        assert_eq!(message_to_event(msg), None);
6907    }
6908
6909    #[test]
6910    fn tool_call_becomes_tool_started_with_name() {
6911        assert_eq!(
6912            message_to_event(message("model", tool_call("call_7", "search"))),
6913            Some(TurnEvent::ToolStarted {
6914                name: "search".to_owned()
6915            })
6916        );
6917    }
6918
6919    #[test]
6920    fn tool_call_falls_back_to_call_id() {
6921        assert_eq!(
6922            message_to_event(message(
6923                "tool",
6924                Some(content::Type::ToolCall(Box::new(ToolCallContent {
6925                    id: "call_bare".to_owned(),
6926                    r#type: None,
6927                    ..Default::default()
6928                })))
6929            )),
6930            Some(TurnEvent::ToolStarted {
6931                name: "call_bare".to_owned()
6932            })
6933        );
6934    }
6935
6936    #[test]
6937    fn tool_result_produces_no_event() {
6938        assert_eq!(
6939            message_to_event(message("tool", tool_result("call_7"))),
6940            None
6941        );
6942    }
6943
6944    // Drives the same per-message mapping the streaming loop uses, over a
6945    // synthetic turn, then appends Done as the End arm would.
6946    fn map_turn(msgs: Vec<Message>) -> Vec<TurnEvent> {
6947        let mut events: Vec<TurnEvent> = msgs.into_iter().filter_map(message_to_event).collect();
6948        events.push(TurnEvent::Done);
6949        events
6950    }
6951
6952    #[test]
6953    fn synthetic_turn_yields_expected_event_sequence() {
6954        // model Text delta, a tool-role ToolCall, another model Text, End.
6955        let turn = vec![
6956            message("model", text("Let me look that up.")),
6957            message("tool", tool_call("call_1", "search")),
6958            message("model", text("Found it.")),
6959        ];
6960        assert_eq!(
6961            map_turn(turn),
6962            vec![
6963                TurnEvent::TextDelta("Let me look that up.".to_owned()),
6964                TurnEvent::ToolStarted {
6965                    name: "search".to_owned()
6966                },
6967                TurnEvent::TextDelta("Found it.".to_owned()),
6968                TurnEvent::Done,
6969            ]
6970        );
6971    }
6972
6973    // --- terminal-envelope projection (events_from_end) ---
6974
6975    use polyc_proto::proto::polychrome::agent::v1::{
6976        Handoff as WireHandoff, PendingApproval as WirePendingApproval,
6977    };
6978
6979    #[test]
6980    fn end_with_nothing_yields_only_done() {
6981        assert_eq!(events_from_end(AgentEnd::default()), vec![TurnEvent::Done]);
6982    }
6983
6984    #[test]
6985    fn end_with_handoff_yields_handoff_then_done() {
6986        let end = AgentEnd {
6987            handoff: buffa::MessageField::some(WireHandoff {
6988                call_id: "call_1".to_owned(),
6989                child_agent_id: "researcher".to_owned(),
6990                reason: "needs deep dive".to_owned(),
6991                ..Default::default()
6992            }),
6993            ..Default::default()
6994        };
6995        assert_eq!(
6996            events_from_end(end),
6997            vec![
6998                TurnEvent::HandoffStarted {
6999                    child_agent_id: "researcher".to_owned(),
7000                    reason: "needs deep dive".to_owned(),
7001                },
7002                TurnEvent::Done,
7003            ]
7004        );
7005    }
7006
7007    #[test]
7008    fn end_orders_approvals_before_handoff_before_done() {
7009        let end = AgentEnd {
7010            pending_approvals: vec![WirePendingApproval {
7011                turn_id: "00000000-0000-0000-0000-000000000001".to_owned(),
7012                request_id: "r1".to_owned(),
7013                tool_name: "delete_file".to_owned(),
7014                args_json: "{}".to_owned(),
7015                title: "Delete a file".to_owned(),
7016                ..Default::default()
7017            }],
7018            handoff: buffa::MessageField::some(WireHandoff {
7019                child_agent_id: "child".to_owned(),
7020                ..Default::default()
7021            }),
7022            ..Default::default()
7023        };
7024        assert_eq!(
7025            events_from_end(end),
7026            vec![
7027                TurnEvent::ApprovalPending {
7028                    turn_id: "00000000-0000-0000-0000-000000000001".to_owned(),
7029                    request_id: "r1".to_owned(),
7030                    tool_name: "delete_file".to_owned(),
7031                    title: "Delete a file".to_owned(),
7032                    args_json: "{}".to_owned(),
7033                    reason: String::new(),
7034                    resolve_token: String::new(),
7035                    preview: None,
7036                },
7037                TurnEvent::HandoffStarted {
7038                    child_agent_id: "child".to_owned(),
7039                    reason: String::new(),
7040                },
7041                TurnEvent::Done,
7042            ]
7043        );
7044    }
7045
7046    #[test]
7047    fn end_projects_invite_deliveries_after_handoff_before_done() {
7048        use polyc_proto::proto::polychrome::agent::v1::InviteDelivery as WireInviteDelivery;
7049        let end = AgentEnd {
7050            invite_deliveries: vec![WireInviteDelivery {
7051                target_user_id: "UVITOR".to_owned(),
7052                code: "482913".to_owned(),
7053                inviter_display: "Ada".to_owned(),
7054                ..Default::default()
7055            }],
7056            ..Default::default()
7057        };
7058        assert_eq!(
7059            events_from_end(end),
7060            vec![
7061                TurnEvent::InviteDelivery {
7062                    target_user_id: "UVITOR".to_owned(),
7063                    code: "482913".to_owned(),
7064                    inviter_display: "Ada".to_owned(),
7065                },
7066                TurnEvent::Done,
7067            ]
7068        );
7069    }
7070
7071    #[test]
7072    fn end_projects_wallet_link_prompt_after_invite_deliveries_before_done() {
7073        use polyc_proto::proto::polychrome::agent::v1::WalletLinkPrompt as WireWalletLinkPrompt;
7074        let end = AgentEnd {
7075            wallet_link_prompt: buffa::MessageField::some(WireWalletLinkPrompt {
7076                link_url: "https://polychrome.example/link/abc".to_owned(),
7077                ..Default::default()
7078            }),
7079            ..Default::default()
7080        };
7081        assert_eq!(
7082            events_from_end(end),
7083            vec![
7084                TurnEvent::WalletLinkPrompt {
7085                    link_url: Some("https://polychrome.example/link/abc".to_owned()),
7086                    renewal: false,
7087                    requested: false,
7088                },
7089                TurnEvent::Done,
7090            ]
7091        );
7092    }
7093
7094    #[test]
7095    fn end_projects_wallet_link_prompt_without_url_as_none() {
7096        use polyc_proto::proto::polychrome::agent::v1::WalletLinkPrompt as WireWalletLinkPrompt;
7097        let end = AgentEnd {
7098            wallet_link_prompt: buffa::MessageField::some(WireWalletLinkPrompt {
7099                link_url: String::new(),
7100                ..Default::default()
7101            }),
7102            ..Default::default()
7103        };
7104        assert_eq!(
7105            events_from_end(end),
7106            vec![
7107                TurnEvent::WalletLinkPrompt {
7108                    link_url: None,
7109                    renewal: false,
7110                    requested: false,
7111                },
7112                TurnEvent::Done,
7113            ]
7114        );
7115    }
7116
7117    /// `#2122`: the renewal bit rides `events_from_end`'s projection too, not
7118    /// just the buffered path.
7119    #[test]
7120    fn end_projects_wallet_link_prompt_renewal_bit() {
7121        use polyc_proto::proto::polychrome::agent::v1::WalletLinkPrompt as WireWalletLinkPrompt;
7122        let end = AgentEnd {
7123            wallet_link_prompt: buffa::MessageField::some(WireWalletLinkPrompt {
7124                link_url: "https://polychrome.example/link/abc".to_owned(),
7125                renewal: true,
7126                ..Default::default()
7127            }),
7128            ..Default::default()
7129        };
7130        assert_eq!(
7131            events_from_end(end),
7132            vec![
7133                TurnEvent::WalletLinkPrompt {
7134                    link_url: Some("https://polychrome.example/link/abc".to_owned()),
7135                    renewal: true,
7136                    requested: false,
7137                },
7138                TurnEvent::Done,
7139            ]
7140        );
7141    }
7142
7143    /// The originally reported bug this PR fixes: a directly-requested
7144    /// `wallet_link` call must project with `requested: true`, distinct from
7145    /// the payment-interrupt signal, so an edge selects the requested-branch
7146    /// card copy.
7147    #[test]
7148    fn end_projects_wallet_link_prompt_requested_bit() {
7149        use polyc_proto::proto::polychrome::agent::v1::WalletLinkPrompt as WireWalletLinkPrompt;
7150        let end = AgentEnd {
7151            wallet_link_prompt: buffa::MessageField::some(WireWalletLinkPrompt {
7152                link_url: "https://polychrome.example/link/abc".to_owned(),
7153                requested: true,
7154                ..Default::default()
7155            }),
7156            ..Default::default()
7157        };
7158        assert_eq!(
7159            events_from_end(end),
7160            vec![
7161                TurnEvent::WalletLinkPrompt {
7162                    link_url: Some("https://polychrome.example/link/abc".to_owned()),
7163                    renewal: false,
7164                    requested: true,
7165                },
7166                TurnEvent::Done,
7167            ]
7168        );
7169    }
7170
7171    #[test]
7172    fn end_projects_wallet_update_prompt_before_done() {
7173        use polyc_proto::proto::polychrome::agent::v1::WalletUpdatePrompt as WireWalletUpdatePrompt;
7174        let end = AgentEnd {
7175            wallet_update_prompt: buffa::MessageField::some(WireWalletUpdatePrompt {
7176                update_url: "https://polychrome.example/update/xyz".to_owned(),
7177                new_limit: "20".to_owned(),
7178                ..Default::default()
7179            }),
7180            ..Default::default()
7181        };
7182        assert_eq!(
7183            events_from_end(end),
7184            vec![
7185                TurnEvent::WalletUpdatePrompt {
7186                    update_url: "https://polychrome.example/update/xyz".to_owned(),
7187                    new_limit: "20".to_owned(),
7188                },
7189                TurnEvent::Done,
7190            ]
7191        );
7192    }
7193
7194    #[test]
7195    fn end_projects_wallet_revoke_prompt_before_done() {
7196        use polyc_proto::proto::polychrome::agent::v1::WalletRevokePrompt as WireWalletRevokePrompt;
7197        let end = AgentEnd {
7198            wallet_revoke_prompt: buffa::MessageField::some(WireWalletRevokePrompt {
7199                revoke_url: "https://polychrome.example/revoke/xyz".to_owned(),
7200                ..Default::default()
7201            }),
7202            ..Default::default()
7203        };
7204        assert_eq!(
7205            events_from_end(end),
7206            vec![
7207                TurnEvent::WalletRevokePrompt {
7208                    revoke_url: "https://polychrome.example/revoke/xyz".to_owned(),
7209                },
7210                TurnEvent::Done,
7211            ]
7212        );
7213    }
7214
7215    // --- namespaced conversation ids ---
7216
7217    #[test]
7218    fn namespaced_id_is_prefix_colon_native() {
7219        assert_eq!(
7220            namespaced_id("mail", "CAF=abc@mail.example"),
7221            "mail:CAF=abc@mail.example"
7222        );
7223        assert_eq!(namespaced_id("web", "abc-123"), "web:abc-123");
7224    }
7225
7226    #[test]
7227    fn hashed_conversation_id_is_deterministic_v5() {
7228        let ns = uuid::Uuid::from_u128(0x1234_5678_9abc_4def_8123_4567_89ab_cdef);
7229        let a = hashed_conversation_id(ns, &["T1", "C1", "169.000"]);
7230        let b = hashed_conversation_id(ns, &["T1", "C1", "169.000"]);
7231        assert_eq!(a, b);
7232        assert_ne!(a, hashed_conversation_id(ns, &["T1", "C2", "169.000"]));
7233        let parsed: uuid::Uuid = a.parse().unwrap();
7234        assert_eq!(parsed.get_version_num(), 5);
7235    }
7236
7237    #[test]
7238    fn framed_conversation_id_resists_separator_collisions() {
7239        let ns = uuid::Uuid::from_u128(0x99);
7240        // The exact case that collides under the bare-join helper.
7241        assert_ne!(
7242            framed_conversation_id(ns, &["a:b", "c"]),
7243            framed_conversation_id(ns, &["a", "b:c"]),
7244        );
7245        // Sanity: the bare-join helper DOES collide here (documents why framed exists).
7246        assert_eq!(
7247            hashed_conversation_id(ns, &["a:b", "c"]),
7248            hashed_conversation_id(ns, &["a", "b:c"]),
7249        );
7250        // Deterministic + valid v5.
7251        let id = framed_conversation_id(ns, &["mail", "<abc@x>"]);
7252        assert_eq!(id, framed_conversation_id(ns, &["mail", "<abc@x>"]));
7253        assert_eq!(id.parse::<uuid::Uuid>().unwrap().get_version_num(), 5);
7254    }
7255
7256    #[test]
7257    fn hashed_conversation_id_matches_slacks_inline_algorithm() {
7258        // Golden cross-check: the SDK helper must reproduce exactly what the
7259        // Slack edge used to compute inline (UUIDv5 of "team:channel:thread"
7260        // under a pinned namespace), so delegating in `polychrome-slack`
7261        // changes no existing conversation id.
7262        let ns = uuid::Uuid::from_u128(0xa1b2_c3d4_e5f6_4789_abcd_ef01_2345_6789);
7263        let parts = ["T01234ABCD", "C0000FAKEID", "1700000000.000100"];
7264        let inline = uuid::Uuid::new_v5(&ns, parts.join(":").as_bytes())
7265            .hyphenated()
7266            .to_string();
7267        assert_eq!(hashed_conversation_id(ns, &parts), inline);
7268    }
7269
7270    #[test]
7271    fn link_outcome_messages_honor_presentation_rules() {
7272        // INVALID and EXPIRED are fused upstream into one variant, so neither
7273        // edge can distinguish them — one shared message, no oracle.
7274        assert!(
7275            LinkCeremony::InvalidOrExpired
7276                .user_message()
7277                .contains("invalid or expired")
7278        );
7279        // THROTTLED reads distinctly (wait, don't retry).
7280        assert!(LinkCeremony::Throttled.user_message().contains("wait"));
7281        assert!(
7282            LinkCeremony::Linked {
7283                persona_id: "p".to_owned()
7284            }
7285            .user_message()
7286            .contains("Linked")
7287        );
7288    }
7289
7290    fn sample_attribution() -> Attribution {
7291        Attribution {
7292            caller: Some(ExternalIdentity {
7293                provider: "slack".to_owned(),
7294                scope: "team-1".to_owned(),
7295                external_id: "U1".to_owned(),
7296                display_name: "Ada".to_owned(),
7297                ..Default::default()
7298            }),
7299            participants: vec![ExternalIdentity {
7300                provider: "slack".to_owned(),
7301                scope: "team-1".to_owned(),
7302                external_id: "U2".to_owned(),
7303                display_name: "Bea".to_owned(),
7304                ..Default::default()
7305            }],
7306        }
7307    }
7308
7309    #[test]
7310    fn build_request_without_credentials_fails_before_transport() {
7311        let ingress = TurnIngress::new(
7312            "slack:team-1:general",
7313            "exec-1",
7314            IngressIdentity::reported("team-1", "message-1").expect("valid source identity"),
7315            ClaimedNamespace::new("slack").expect("a real namespace is a valid claim"),
7316            vec![text_message("user", "hi")],
7317        );
7318        assert!(
7319            matches!(
7320                build_request(ingress, None),
7321                Err(DialError::MissingIngressCredentials)
7322            ),
7323            "source identity cannot ride outside a signed envelope"
7324        );
7325    }
7326
7327    #[test]
7328    fn build_request_with_credentials_signs_a_verifiable_envelope() {
7329        let key_bytes = [7u8; 32];
7330        let signer_pk = polyc_crypto::Signer::from_key_bytes(&key_bytes)
7331            .expect("valid test key material")
7332            .public_key_bytes();
7333        let creds = EdgeCredentials::from_parts(
7334            "slack".to_owned(),
7335            "pc_slack_test-secret".to_owned(),
7336            &polyc_crypto::hex::lower(&key_bytes),
7337        )
7338        .expect("valid test credentials");
7339        let attribution = sample_attribution();
7340
7341        let source_identity =
7342            IngressIdentity::reported("team-1", "message-1").expect("valid source identity");
7343        let ingress = TurnIngress::new(
7344            "slack:team-1:general",
7345            "exec-1",
7346            source_identity.clone(),
7347            ClaimedNamespace::new("slack").expect("a real namespace is a valid claim"),
7348            vec![text_message("user", "hi")],
7349        )
7350        .with_attribution(attribution.clone());
7351        let request = build_request(ingress, Some(&creds)).expect("credentialed request builds");
7352
7353        let start = request.start.into_option().expect("start set");
7354        let envelope = start
7355            .asserted_attribution
7356            .into_option()
7357            .expect("a credentialed dial signs an asserted_attribution envelope");
7358
7359        assert_eq!(envelope.edge_id, "slack");
7360        assert_eq!(envelope.conversation_id, "slack:team-1:general");
7361        assert!(!envelope.nonce.is_empty(), "nonce must be set per turn");
7362        assert!(envelope.issued_unix_ms > 0);
7363        assert!(
7364            polyc_crypto::edge_identity::verify_edge_assertion(&signer_pk, &envelope),
7365            "the signed envelope must verify under the credential's own public key"
7366        );
7367        let mut tampered_source = envelope.clone();
7368        tampered_source.source_identity = buffa::MessageField::some(
7369            IngressIdentity::reported("team-1", "message-2")
7370                .expect("valid source identity")
7371                .to_wire(),
7372        );
7373        assert!(
7374            !polyc_crypto::edge_identity::verify_edge_assertion(&signer_pk, &tampered_source),
7375            "source identity must be covered by the edge signature"
7376        );
7377        assert_eq!(
7378            envelope.caller.into_option().map(|c| c.external_id),
7379            attribution.caller.map(|c| c.external_id),
7380            "the envelope's caller must match the turn's attribution"
7381        );
7382        assert_eq!(
7383            envelope
7384                .participants
7385                .into_iter()
7386                .map(|p| p.external_id)
7387                .collect::<Vec<_>>(),
7388            attribution
7389                .participants
7390                .into_iter()
7391                .map(|p| p.external_id)
7392                .collect::<Vec<_>>(),
7393            "the envelope's participants must match the turn's attribution"
7394        );
7395        assert_eq!(
7396            envelope.exec_id, "exec-1",
7397            "the envelope must bind this turn's exec_id"
7398        );
7399        assert_eq!(
7400            envelope.content_hash,
7401            polyc_crypto::edge_identity::content_hash_hex(&encode_messages_for_content_hash(&[
7402                text_message("user", "hi")
7403            ])),
7404            "the envelope's content_hash must match the turn's messages"
7405        );
7406        assert_eq!(
7407            envelope.source_identity.into_option(),
7408            Some(source_identity.to_wire()),
7409            "the stable source identity must ride inside the signed envelope"
7410        );
7411    }
7412
7413    #[test]
7414    fn source_identity_and_content_digest_do_not_follow_exec_id() {
7415        let creds = EdgeCredentials::from_parts(
7416            "edge".to_owned(),
7417            "bearer".to_owned(),
7418            &polyc_crypto::hex::lower(&[8; 32]),
7419        )
7420        .expect("valid credentials");
7421        let source =
7422            IngressIdentity::reported("source", "event-42").expect("valid source identity");
7423        let messages = vec![text_message("user", "same authenticated content")];
7424
7425        let envelope = |exec_id: &str| {
7426            build_request(
7427                TurnIngress::new(
7428                    "conv",
7429                    exec_id,
7430                    source.clone(),
7431                    ClaimedNamespace::new("slack").expect("a real namespace is a valid claim"),
7432                    messages.clone(),
7433                ),
7434                Some(&creds),
7435            )
7436            .expect("request builds")
7437            .start
7438            .into_option()
7439            .expect("start set")
7440            .asserted_attribution
7441            .into_option()
7442            .expect("assertion set")
7443        };
7444        let first = envelope("attempt-1");
7445        let retry = envelope("attempt-2");
7446
7447        assert_ne!(first.exec_id, retry.exec_id);
7448        assert_eq!(first.source_identity, retry.source_identity);
7449        assert_eq!(first.content_hash, retry.content_hash);
7450    }
7451
7452    #[test]
7453    fn transport_success_without_complete_receipt_is_not_acknowledgement_authority() {
7454        let source = IngressIdentity::reported("source", "event-42")
7455            .expect("valid source identity")
7456            .to_wire();
7457        let valid = WireIngressReceipt {
7458            source_identity: buffa::MessageField::some(source.clone()),
7459            receipt: b"receipt".to_vec(),
7460            dispatch_id: "dispatch".to_owned(),
7461            conversation_id: "conv".to_owned(),
7462            ..Default::default()
7463        };
7464        assert!(validate_ingress_receipt(&valid, &source, "conv").is_ok());
7465
7466        let malformed = [
7467            WireIngressReceipt {
7468                source_identity: buffa::MessageField::none(),
7469                ..valid.clone()
7470            },
7471            WireIngressReceipt {
7472                receipt: Vec::new(),
7473                ..valid.clone()
7474            },
7475            WireIngressReceipt {
7476                dispatch_id: String::new(),
7477                ..valid.clone()
7478            },
7479            WireIngressReceipt {
7480                conversation_id: "other".to_owned(),
7481                ..valid
7482            },
7483        ];
7484        for receipt in malformed {
7485            assert!(matches!(
7486                validate_ingress_receipt(&receipt, &source, "conv"),
7487                Err(DialError::InvalidIngressReceipt(_))
7488            ));
7489        }
7490    }
7491
7492    // `#1553`: the responder an edge names on a decision is signed here, once,
7493    // so every edge inherits it. The tests below pin what the assertion covers,
7494    // the two states that legitimately carry none, and the wiring that gets the
7495    // edge's key from its credentials to the request.
7496
7497    /// A dial address nothing connects to — every dialer below is built for
7498    /// the request it produces, never for a call.
7499    const TEST_ADDR: &str = "http://127.0.0.1:0";
7500
7501    /// Test credentials plus the public key their assertions verify under.
7502    fn approval_credentials() -> (EdgeCredentials, Vec<u8>) {
7503        let key_bytes = [11u8; 32];
7504        let public_key = polyc_crypto::Signer::from_key_bytes(&key_bytes)
7505            .expect("valid test key material")
7506            .public_key_bytes();
7507        let creds = EdgeCredentials::from_parts(
7508            "slack".to_owned(),
7509            "pc_slack_test-secret".to_owned(),
7510            &polyc_crypto::hex::lower(&key_bytes),
7511        )
7512        .expect("valid test credentials");
7513        (creds, public_key)
7514    }
7515
7516    fn sample_responder() -> ExternalIdentity {
7517        ExternalIdentity {
7518            provider: "slack".to_owned(),
7519            scope: "team-1".to_owned(),
7520            external_id: "U1".to_owned(),
7521            display_name: "Ada".to_owned(),
7522            ..Default::default()
7523        }
7524    }
7525
7526    /// The request an approving edge that named a responder sends — the shape
7527    /// every assertion test below starts from.
7528    fn approve_naming_a_responder(dialer: &ApprovalDialer) -> ApprovalResponseRequest {
7529        dialer.respond_request(
7530            "00000000-0000-0000-0000-000000000001",
7531            "req-1",
7532            ApprovalChoice::Approve,
7533            "looks right",
7534            "slack:team-1:general",
7535            r#"{"path":"/tmp/a"}"#,
7536            "context",
7537            "resolve-token-1",
7538            Some(sample_responder()),
7539        )
7540    }
7541
7542    #[test]
7543    fn a_credentialed_dialer_signs_a_verifiable_assertion() {
7544        let (creds, public_key) = approval_credentials();
7545        let dialer = ApprovalDialer::with_credentials(TEST_ADDR, Arc::new(creds))
7546            .expect("a credentialed approval dialer builds");
7547        let request = approve_naming_a_responder(&dialer);
7548
7549        let assertion = request
7550            .asserted_approval
7551            .as_option()
7552            .expect("a credentialed edge naming a responder asserts one");
7553        assert_eq!(assertion.edge_id, "slack");
7554        assert_eq!(
7555            assertion
7556                .responder
7557                .as_option()
7558                .map(|r| r.external_id.as_str()),
7559            Some("U1"),
7560            "the assertion must carry the responder the edge named"
7561        );
7562        assert!(
7563            polyc_crypto::approval_assertion::verify_approval_assertion(
7564                [public_key.as_slice()],
7565                &request
7566            ),
7567            "the assertion must verify under the edge's own identity key"
7568        );
7569    }
7570
7571    #[test]
7572    fn a_decision_naming_no_responder_asserts_none() {
7573        // Decision 4: a machine decision, or an edge that cannot attribute the
7574        // human, asserts nothing — a supported state, not an error.
7575        let (creds, _) = approval_credentials();
7576        let dialer = ApprovalDialer::with_credentials(TEST_ADDR, Arc::new(creds))
7577            .expect("a credentialed approval dialer builds");
7578        let request = dialer.respond_request(
7579            "00000000-0000-0000-0000-000000000001",
7580            "req-1",
7581            ApprovalChoice::Deny,
7582            "no",
7583            "slack:team-1:general",
7584            "",
7585            "",
7586            "resolve-token-1",
7587            None,
7588        );
7589        assert!(
7590            request.asserted_approval.as_option().is_none(),
7591            "no responder means no assertion, not an assertion of nobody"
7592        );
7593    }
7594
7595    #[test]
7596    fn an_uncredentialed_dialer_asserts_none() {
7597        // The `new`/`with_bearer` paths: no key to sign with, so the responder
7598        // the edge named goes unasserted rather than riding unsigned.
7599        for (label, dialer) in [
7600            (
7601                "new",
7602                ApprovalDialer::new(TEST_ADDR).expect("an unauthenticated dialer builds"),
7603            ),
7604            (
7605                "with_bearer",
7606                ApprovalDialer::with_bearer(TEST_ADDR, "pc_slack_test-secret")
7607                    .expect("a bearer-only dialer builds"),
7608            ),
7609        ] {
7610            let request = approve_naming_a_responder(&dialer);
7611            assert!(
7612                request.asserted_approval.as_option().is_none(),
7613                "{label}: an uncredentialed dialer must not put an unsigned responder on the wire"
7614            );
7615        }
7616    }
7617
7618    #[test]
7619    fn the_assertion_covers_every_field_of_the_final_request() {
7620        // The attach-last ordering, pinned from the outside: the signature is
7621        // over the FINISHED request, so mutating ANY signed field after the
7622        // builder returns must break verification. A builder that attached the
7623        // assertion before some field was final would ship a request that fails
7624        // this same check on the control plane.
7625        let (creds, public_key) = approval_credentials();
7626        let dialer = ApprovalDialer::with_credentials(TEST_ADDR, Arc::new(creds))
7627            .expect("a credentialed approval dialer builds");
7628        let keys = || [public_key.as_slice()];
7629
7630        /// One "change a signed field after the fact" case.
7631        type Mutation = (&'static str, fn(&mut ApprovalResponseRequest));
7632
7633        let mutations: Vec<Mutation> = vec![
7634            ("request_id", |r| r.request_id = "req-2".to_owned()),
7635            ("conversation_id", |r| {
7636                r.conversation_id = "slack:team-1:secrets".to_owned();
7637            }),
7638            ("resolve_token", |r| {
7639                r.resolve_token = "resolve-token-2".to_owned();
7640            }),
7641            ("decision", |r| {
7642                use polyc_proto::proto::polychrome::approval::v1::{
7643                    Deny, approval_response_request::Decision,
7644                };
7645                r.decision = Some(Decision::Deny(Box::new(Deny {
7646                    reason: "looks right".to_owned(),
7647                    abort: false,
7648                    __buffa_unknown_fields: buffa::UnknownFields::default(),
7649                })));
7650            }),
7651            ("modified_args_json", |r| {
7652                use polyc_proto::proto::polychrome::approval::v1::approval_response_request::Decision;
7653                let Some(Decision::Approve(approve)) = r.decision.as_mut() else {
7654                    panic!("the builder produced an Approve");
7655                };
7656                approve.modified_args_json = r#"{"path":"/etc/shadow"}"#.to_owned();
7657            }),
7658            ("responder", |r| {
7659                r.asserted_approval
7660                    .as_option_mut()
7661                    .expect("the request carries an assertion")
7662                    .responder
7663                    .as_option_mut()
7664                    .expect("the assertion carries a responder")
7665                    .external_id = "U0EVE".to_owned();
7666            }),
7667            ("edge_id", |r| {
7668                r.asserted_approval
7669                    .as_option_mut()
7670                    .expect("the request carries an assertion")
7671                    .edge_id = "messaging".to_owned();
7672            }),
7673        ];
7674
7675        for (field, mutate) in mutations {
7676            let mut request = approve_naming_a_responder(&dialer);
7677            assert!(
7678                polyc_crypto::approval_assertion::verify_approval_assertion(keys(), &request),
7679                "{field}: the unmutated request must verify, or this case proves nothing"
7680            );
7681            mutate(&mut request);
7682            assert!(
7683                !polyc_crypto::approval_assertion::verify_approval_assertion(keys(), &request),
7684                "{field}: changing it after signing must invalidate the assertion"
7685            );
7686        }
7687    }
7688
7689    #[test]
7690    fn an_approval_dialer_inherits_the_agent_dialers_edge_identity() {
7691        // The wiring an edge actually uses: one credential, one key, shared by
7692        // the turn-dispatch dialer and the approval dialer built from it.
7693        let (creds, public_key) = approval_credentials();
7694        let agent = AgentDialer::with_credentials(TEST_ADDR, creds)
7695            .expect("a credentialed agent dialer builds");
7696        let approval = agent
7697            .approval_dialer_with_credentials(TEST_ADDR)
7698            .expect("the approval sibling builds");
7699        assert!(
7700            polyc_crypto::approval_assertion::verify_approval_assertion(
7701                [public_key.as_slice()],
7702                &approve_naming_a_responder(&approval)
7703            ),
7704            "the approval dialer must sign with the SAME key the agent dialer dispatches with"
7705        );
7706
7707        // And the unauthenticated path has nothing to inherit.
7708        let plain = AgentDialer::new(TEST_ADDR)
7709            .expect("an unauthenticated agent dialer builds")
7710            .approval_dialer_with_credentials(TEST_ADDR)
7711            .expect("the approval sibling builds");
7712        assert!(
7713            approve_naming_a_responder(&plain)
7714                .asserted_approval
7715                .as_option()
7716                .is_none(),
7717            "an unauthenticated agent dialer has no key to lend, so its approvals assert nobody"
7718        );
7719    }
7720
7721    #[test]
7722    fn with_credentials_rejects_a_bearer_that_is_not_a_valid_header_value() {
7723        let creds = EdgeCredentials::from_parts(
7724            "slack".to_owned(),
7725            "pc_slack_bad\nbearer".to_owned(),
7726            &polyc_crypto::hex::lower(&[9u8; 32]),
7727        )
7728        .expect("valid test credentials");
7729        // `AgentDialer` isn't `Debug` (it wraps a connect client), so match
7730        // directly instead of `.expect_err`, which requires `T: Debug`.
7731        let err = match AgentDialer::with_credentials("http://127.0.0.1:0", creds) {
7732            Ok(_) => panic!("a newline in the bearer must fail the dial closed"),
7733            Err(err) => err,
7734        };
7735        assert!(matches!(err, DialError::InvalidBearer(_)));
7736    }
7737
7738    // [`bearer_header`] is the ONE place a bearer becomes an HTTP header, and
7739    // every bearer-carrying constructor reaches it through
7740    // [`build_control_client`]. The two tests below pin that logic directly;
7741    // the table-driven one after them proves each dialer actually routes
7742    // through it rather than re-implementing the header itself.
7743
7744    #[test]
7745    fn bearer_header_encodes_a_valid_bearer() {
7746        let headers = bearer_header("pc_slack_good").expect("a valid bearer encodes");
7747        let value = headers
7748            .get(http::header::AUTHORIZATION)
7749            .expect("authorization header is set");
7750        assert_eq!(value, "Bearer pc_slack_good");
7751        assert!(
7752            value.is_sensitive(),
7753            "the bearer header must be marked sensitive so the HTTP stack \
7754             never logs it or HPACK-indexes it"
7755        );
7756    }
7757
7758    #[test]
7759    fn bearer_header_rejects_a_bearer_that_is_not_a_valid_header_value() {
7760        let err =
7761            bearer_header("pc_slack_bad\nbearer").expect_err("a newline must fail the dial closed");
7762        assert!(matches!(err, DialError::InvalidBearer(_)));
7763    }
7764
7765    #[test]
7766    fn bearer_header_value_is_sensitive() {
7767        let value = bearer_header_value("pc_admin_good").expect("a valid bearer encodes");
7768        assert_eq!(value, "Bearer pc_admin_good");
7769        assert!(
7770            value.is_sensitive(),
7771            "RoutineDialer::fire_routine's per-call admin bearer must be \
7772             marked sensitive, same as every other dialer's bearer header"
7773        );
7774    }
7775
7776    /// Wiring guard for every dialer whose auth is a bearer header (none of
7777    /// them sends `AgentStart`, so no turn envelope rides their calls): a
7778    /// valid bearer constructs, and one that isn't a valid header value fails
7779    /// closed with
7780    /// [`DialError::InvalidBearer`]. Table-driven so adding a dialer is one
7781    /// line here rather than another pair of copy-pasted tests. Each closure
7782    /// erases the dialer's own (non-`Debug`) type so both outcomes assert
7783    /// uniformly.
7784    #[test]
7785    fn every_bearer_dialer_routes_through_bearer_header() {
7786        const ADDR: &str = "http://127.0.0.1:0";
7787        const GOOD: &str = "pc_slack_good";
7788        const BAD: &str = "pc_slack_bad\nbearer";
7789
7790        type Build = fn(&str, &str) -> Result<(), DialError>;
7791        let dialers: &[(&str, Build)] = &[
7792            ("ApprovalDialer", |a, b| {
7793                ApprovalDialer::with_bearer(a, b).map(|_| ())
7794            }),
7795            // The credentialed approval path rides the SAME header (its
7796            // credentials' bearer), so it belongs to this guard too.
7797            ("ApprovalDialer::with_credentials", |a, b| {
7798                let creds = EdgeCredentials::from_parts(
7799                    "slack".to_owned(),
7800                    b.to_owned(),
7801                    &polyc_crypto::hex::lower(&[7u8; 32]),
7802                )
7803                .expect("valid test credentials");
7804                ApprovalDialer::with_credentials(a, Arc::new(creds)).map(|_| ())
7805            }),
7806            ("PersonaDialer", |a, b| {
7807                PersonaDialer::with_bearer(a, b).map(|_| ())
7808            }),
7809            // Admin-gated `new_admin` constructors are a separate code path
7810            // from `with_bearer` — they must independently route through
7811            // `bearer_header` rather than `with_default_header`, which
7812            // silently drops an invalid value instead of failing the dial
7813            // closed. See `#1811`.
7814            ("PersonaDialer::new_admin", |a, b| {
7815                PersonaDialer::new_admin(a, b).map(|_| ())
7816            }),
7817            ("CredentialDialer::new_admin", |a, b| {
7818                CredentialDialer::new_admin(a, b).map(|_| ())
7819            }),
7820            ("NotificationDialer", |a, b| {
7821                NotificationDialer::with_bearer(a, b).map(|_| ())
7822            }),
7823            ("OperatorMailboxDialer", |a, b| {
7824                OperatorMailboxDialer::with_bearer(a, b).map(|_| ())
7825            }),
7826            // `#1660` incident (2026-07-28): QuestionDialer shipped with
7827            // ONLY `new()` (no bearer) on the assumption that the
7828            // per-question `answer_token` was sufficient auth. The control
7829            // plane's `require_edge_bearer` layer guards EVERY RPC on this
7830            // listener regardless of per-call authorization, so every real
7831            // answer submission 401'd in production — this dialer was never
7832            // in this guard, so nothing caught the gap before a live user
7833            // did.
7834            ("QuestionDialer", |a, b| {
7835                QuestionDialer::with_bearer(a, b).map(|_| ())
7836            }),
7837            // `RoutineDialer::fire_routine` isn't a dialer constructor —
7838            // `RoutineDialer::new` dials without a bearer — but its per-call
7839            // `admin_bearer` header routes through the SAME
7840            // `bearer_header_value` construction site every other row here
7841            // reaches through `bearer_header`, so it belongs in this guard
7842            // too. `_a` is unused: there's no address to dial.
7843            ("RoutineDialer::fire_routine", |_a, b| {
7844                bearer_header_value(b).map(|_| ())
7845            }),
7846        ];
7847
7848        for (label, build) in dialers {
7849            build(ADDR, GOOD)
7850                .unwrap_or_else(|err| panic!("{label}: a valid bearer must build a dialer: {err}"));
7851            match build(ADDR, BAD) {
7852                Ok(()) => panic!("{label}: a newline in the bearer must fail the dial closed"),
7853                Err(err) => assert!(
7854                    matches!(err, DialError::InvalidBearer(_)),
7855                    "{label}: expected InvalidBearer, got {err}"
7856                ),
7857            }
7858        }
7859    }
7860
7861    /// Wiring guard for `RoutineDialer::fire_routine` itself (as opposed to
7862    /// the `bearer_header_value` unit call the table above uses as a stand-in
7863    /// for it): a malformed `admin_bearer` must fail closed with
7864    /// [`DialError::InvalidBearer`] before any dial is attempted, so
7865    /// `127.0.0.1:0` (nothing listening) never gets reached.
7866    #[tokio::test]
7867    async fn fire_routine_rejects_a_malformed_admin_bearer_before_dialing() {
7868        let dialer = RoutineDialer::new("http://127.0.0.1:0").expect("valid address");
7869
7870        let err = dialer
7871            .fire_routine("standup", "", sample_responder(), "pc_admin_bad\nbearer")
7872            .await
7873            .expect_err("a newline in admin_bearer must fail the dial closed");
7874
7875        assert!(
7876            matches!(err, DialError::InvalidBearer(_)),
7877            "expected InvalidBearer, got {err}"
7878        );
7879    }
7880
7881    #[test]
7882    fn recovery_conversion_preserves_every_display_projection() {
7883        use polyc_proto::proto::polychrome::approval::v1::{
7884            RecordedApprovalDecision, SearchPreview,
7885        };
7886
7887        let wire = RecoverableApprovalEntry {
7888            turn_id: "turn-1".to_owned(),
7889            request_id: "call-1".to_owned(),
7890            tool_name: "conversation_search".to_owned(),
7891            title: "Search past conversations".to_owned(),
7892            args_json: r#"{"query":"release"}"#.to_owned(),
7893            reason: "Review the search scope.".to_owned(),
7894            preview: buffa::MessageField::none(),
7895            read_preview: buffa::MessageField::some(WireListReadPreview {
7896                detail: Some(wire_list_read_preview::Detail::Search(Box::new(
7897                    SearchPreview {
7898                        canonical_query: "release decision".to_owned(),
7899                        scope_count: 7,
7900                        __buffa_unknown_fields: buffa::UnknownFields::default(),
7901                    },
7902                ))),
7903                __buffa_unknown_fields: buffa::UnknownFields::default(),
7904            }),
7905            state: Some(recoverable_approval_entry::State::DecisionRecorded(
7906                Box::new(RecordedApprovalDecision {
7907                    approved: true,
7908                    __buffa_unknown_fields: buffa::UnknownFields::default(),
7909                }),
7910            )),
7911            __buffa_unknown_fields: buffa::UnknownFields::default(),
7912        };
7913
7914        let recovered = RecoverableApproval::try_from(wire).expect("valid recovery projection");
7915        assert_eq!(recovered.turn_id, "turn-1");
7916        assert_eq!(recovered.request_id, "call-1");
7917        assert_eq!(recovered.tool_name, "conversation_search");
7918        assert_eq!(recovered.title, "Search past conversations");
7919        assert_eq!(recovered.args_json, r#"{"query":"release"}"#);
7920        assert_eq!(recovered.reason, "Review the search scope.");
7921        assert_eq!(
7922            recovered.read_preview,
7923            Some(ReadPreview::Search {
7924                canonical_query: "release decision".to_owned(),
7925                scope_count: 7,
7926            })
7927        );
7928        assert_eq!(
7929            recovered.state,
7930            ApprovalRecoveryState::DecisionRecorded(RecordedApprovalRecovery { approved: true })
7931        );
7932    }
7933
7934    /// Build a preview whose runs all sit on the cadence line's zone.
7935    fn standup_preview() -> ApprovalPreview {
7936        ApprovalPreview {
7937            routine_name: "standup".to_owned(),
7938            prompt_text: "Post the daily standup summary.".to_owned(),
7939            next_fires: vec![
7940                fire("2026-07-29T09:00:00-07:00", "2026-07-29T16:00:00Z", ""),
7941                fire("2026-07-30T09:00:00-07:00", "2026-07-30T16:00:00Z", ""),
7942                fire("2026-07-31T09:00:00-07:00", "2026-07-31T16:00:00Z", ""),
7943            ],
7944            zone_name: "America/Los_Angeles".to_owned(),
7945            zone_is_fallback: false,
7946            cadence: "every weekday at 9:00 AM PDT (UTC-7)".to_owned(),
7947        }
7948    }
7949
7950    fn fire(local: &str, utc: &str, zone_label: &str) -> ApprovalPreviewFire {
7951        ApprovalPreviewFire {
7952            local_time: local.to_owned(),
7953            utc_time: utc.to_owned(),
7954            zone_label: zone_label.to_owned(),
7955        }
7956    }
7957
7958    fn at(rfc3339: &str) -> DateTime<Utc> {
7959        rfc3339.parse().expect("valid RFC3339")
7960    }
7961
7962    /// `#1496`/`#1808`: the shared preview-render helper — the ONE place
7963    /// every chat edge's approval-card text comes from, so they can't word
7964    /// the same preview differently. Asserted whole, because the layout IS
7965    /// the deliverable here: a per-line spot check would pass on a table
7966    /// whose columns had silently stopped lining up.
7967    #[test]
7968    fn approval_preview_text_renders_cadence_then_an_aligned_run_table() {
7969        let text = approval_preview_text(&standup_preview(), at("2026-07-28T17:00:00Z"));
7970        assert_eq!(
7971            text,
7972            "This routine will run:\n\
7973             \n\
7974             Post the daily standup summary.\n\
7975             \n\
7976             Runs every weekday at 9:00 AM PDT (UTC-7)\n\
7977             \n\
7978             Next 3 runs\n\
7979             ```\n\
7980             \u{20} Wed Jul 29    9:00 AM    tomorrow\n\
7981             \u{20} Thu Jul 30    9:00 AM    in 2 days\n\
7982             \u{20} Fri Jul 31    9:00 AM    in 3 days\n\
7983             ```"
7984        );
7985    }
7986
7987    #[test]
7988    fn approval_preview_plain_text_keeps_copy_and_removes_markup() {
7989        let preview = standup_preview();
7990        let now = at("2026-07-28T17:00:00Z");
7991        let chat = approval_preview_text(&preview, now);
7992        let plain = approval_preview_plain_text(&preview, now);
7993        assert!(chat.contains("```"));
7994        assert!(!plain.contains("```"));
7995        assert!(plain.contains("This routine will run:"));
7996        assert!(plain.contains("Wed Jul 29    9:00 AM    tomorrow"));
7997    }
7998
7999    /// The card states the schedule, never the raw instants it was computed
8000    /// from: no RFC3339, and no UTC column duplicating the local time.
8001    #[test]
8002    fn approval_preview_text_shows_no_machine_timestamps() {
8003        let text = approval_preview_text(&standup_preview(), at("2026-07-28T17:00:00Z"));
8004        assert!(!text.contains("2026-07-29T09:00:00-07:00"), "{text}");
8005        assert!(!text.contains("2026-07-29T16:00:00Z"), "{text}");
8006        assert!(
8007            !text.contains('Z'),
8008            "no UTC instants belong on the card: {text}"
8009        );
8010    }
8011
8012    /// "fire" is internal vocabulary — a person reading this card sees
8013    /// "runs" everywhere, per this repo's user-facing-copy rule.
8014    #[test]
8015    fn approval_preview_text_never_says_fire() {
8016        let mut empty = standup_preview();
8017        empty.next_fires = Vec::new();
8018        for preview in [standup_preview(), empty] {
8019            let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8020            assert!(
8021                !text.to_lowercase().contains("fire"),
8022                "user-facing copy must say runs, not fires: {text}"
8023            );
8024        }
8025    }
8026
8027    /// The title counts the runs it actually has — a schedule with fewer
8028    /// than three left must not claim three, and one run reads as singular.
8029    #[test]
8030    fn approval_preview_text_titles_the_list_by_its_real_count() {
8031        let mut preview = standup_preview();
8032        preview.next_fires.truncate(2);
8033        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8034        assert!(text.contains("Next 2 runs"), "{text}");
8035
8036        preview.next_fires.truncate(1);
8037        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8038        assert!(text.contains("Next run"), "{text}");
8039        assert!(!text.contains("Next 1 runs"), "{text}");
8040    }
8041
8042    /// When the control plane could not describe the schedule, the card says
8043    /// nothing about cadence rather than guessing — the run list still
8044    /// carries the truth.
8045    #[test]
8046    fn approval_preview_text_omits_an_absent_cadence_line() {
8047        let mut preview = standup_preview();
8048        preview.cadence = String::new();
8049        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8050        assert!(!text.contains("Runs "), "{text}");
8051        assert!(text.contains("Next 3 runs"), "{text}");
8052    }
8053
8054    /// A run carrying its own zone label (a daylight-saving straddle, or a
8055    /// cadence with no single clock time) prints it in the time column.
8056    #[test]
8057    fn approval_preview_text_puts_a_runs_own_zone_label_beside_its_time() {
8058        let preview = ApprovalPreview {
8059            routine_name: "standup".to_owned(),
8060            prompt_text: "prompt".to_owned(),
8061            next_fires: vec![
8062                fire(
8063                    "2026-10-31T09:00:00-07:00",
8064                    "2026-10-31T16:00:00Z",
8065                    "PDT (UTC-7)",
8066                ),
8067                fire(
8068                    "2026-11-01T09:00:00-08:00",
8069                    "2026-11-01T17:00:00Z",
8070                    "PST (UTC-8)",
8071                ),
8072            ],
8073            zone_name: "America/Los_Angeles".to_owned(),
8074            zone_is_fallback: false,
8075            cadence: "every day at 9:00 AM".to_owned(),
8076        };
8077        let text = approval_preview_text(&preview, at("2026-10-30T17:00:00Z"));
8078        assert!(text.contains("9:00 AM PDT (UTC-7)    tomorrow"), "{text}");
8079        assert!(text.contains("9:00 AM PST (UTC-8)    in 2 days"), "{text}");
8080    }
8081
8082    /// The relative column is anchored at RENDER time, so a preview frozen
8083    /// in the durable event and recovered days later cannot still claim
8084    /// "tomorrow". A run already behind `now` reads `passed`.
8085    #[test]
8086    fn approval_preview_text_anchors_runs_against_render_time() {
8087        let preview = standup_preview();
8088        let text = approval_preview_text(&preview, at("2026-07-30T17:00:00Z"));
8089        assert!(text.contains("Wed Jul 29    9:00 AM    passed"), "{text}");
8090        assert!(text.contains("Thu Jul 30    9:00 AM    passed"), "{text}");
8091        assert!(text.contains("Fri Jul 31    9:00 AM    tomorrow"), "{text}");
8092    }
8093
8094    /// "today" means the reader's calendar day, not UTC's — the two disagree
8095    /// for part of every day across most of the world.
8096    #[test]
8097    fn approval_preview_text_reckons_days_in_the_runs_own_offset() {
8098        let preview = standup_preview();
8099        // 16:00Z is 09:00 in UTC-7, so the first run is later TODAY locally
8100        // even though UTC has not yet reached it.
8101        let text = approval_preview_text(&preview, at("2026-07-29T15:00:00Z"));
8102        assert!(text.contains("Wed Jul 29    9:00 AM    today"), "{text}");
8103    }
8104
8105    /// A run in another calendar year carries the year, so a schedule that
8106    /// crosses New Year cannot read as if it ran this year.
8107    #[test]
8108    fn approval_preview_text_adds_the_year_when_it_differs() {
8109        let preview = ApprovalPreview {
8110            routine_name: "standup".to_owned(),
8111            prompt_text: "prompt".to_owned(),
8112            next_fires: vec![fire(
8113                "2027-01-01T09:00:00-08:00",
8114                "2027-01-01T17:00:00Z",
8115                "",
8116            )],
8117            zone_name: "America/Los_Angeles".to_owned(),
8118            zone_is_fallback: false,
8119            cadence: "once".to_owned(),
8120        };
8121        let text = approval_preview_text(&preview, at("2026-12-30T17:00:00Z"));
8122        assert!(text.contains("Fri Jan  1, 2027"), "{text}");
8123    }
8124
8125    /// A `once` schedule renders `Runs once`, a `Next run` title (singular —
8126    /// there is only ever one), and the run's own zone label beside its time,
8127    /// since "once" names no clock time for the label to ride on.
8128    #[test]
8129    fn approval_preview_text_renders_a_once_schedule_end_to_end() {
8130        let preview = ApprovalPreview {
8131            routine_name: "standup".to_owned(),
8132            prompt_text: "Post the launch announcement.".to_owned(),
8133            next_fires: vec![fire(
8134                "2026-07-29T09:00:00-07:00",
8135                "2026-07-29T16:00:00Z",
8136                "PDT (UTC-7)",
8137            )],
8138            zone_name: "America/Los_Angeles".to_owned(),
8139            zone_is_fallback: false,
8140            cadence: "once".to_owned(),
8141        };
8142        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8143        assert!(text.contains("Runs once"), "{text}");
8144        assert!(text.contains("Next run"), "{text}");
8145        assert!(!text.contains("Next 1 run"), "{text}");
8146        assert!(text.contains("9:00 AM PDT (UTC-7)    tomorrow"), "{text}");
8147    }
8148
8149    /// The zone fallback is LABELED, never a silent guess (acceptance
8150    /// criterion 3 / INV-RL3's "best-effort" assumption), and keeps the IANA
8151    /// name — an abbreviation cannot tell a reader WHICH zone got guessed.
8152    #[test]
8153    fn approval_preview_text_labels_the_zone_fallback() {
8154        let mut preview = standup_preview();
8155        preview.zone_is_fallback = true;
8156        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8157        assert!(
8158            text.contains("don't know your time zone"),
8159            "a fallback zone must be disclosed, not silently guessed: {text}"
8160        );
8161        assert!(text.contains("America/Los_Angeles"), "{text}");
8162    }
8163
8164    /// An empty schedule states the CONSEQUENCE, not just the fact — the
8165    /// reader is deciding whether to approve it.
8166    #[test]
8167    fn approval_preview_text_handles_no_upcoming_runs() {
8168        let mut preview = standup_preview();
8169        preview.next_fires = Vec::new();
8170        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8171        assert!(
8172            text.contains("no upcoming runs, so approving it won't run anything"),
8173            "{text}"
8174        );
8175        assert!(
8176            !text.contains("Next"),
8177            "an empty schedule has no run list: {text}"
8178        );
8179    }
8180
8181    /// An unparseable instant keeps its run on the card rather than dropping
8182    /// it — a card silently listing two of three runs understates the
8183    /// schedule being approved.
8184    #[test]
8185    fn approval_preview_text_keeps_a_run_whose_instant_cannot_be_parsed() {
8186        let mut preview = standup_preview();
8187        preview.next_fires[1] = fire("not-an-instant", "", "");
8188        let text = approval_preview_text(&preview, at("2026-07-28T17:00:00Z"));
8189        assert!(text.contains("not-an-instant"), "{text}");
8190        assert!(text.contains("Next 3 runs"), "{text}");
8191    }
8192
8193    /// The `ListPending` recovery wire type and the live-card wire type
8194    /// convert to the SAME [`ApprovalPreview`] — a recovered card cannot
8195    /// render a different preview than the live one showed.
8196    #[test]
8197    fn agent_and_list_wire_previews_convert_to_the_same_shape() {
8198        let agent_wire = WireAgentApprovalPreview {
8199            prompt_text: "prompt".to_owned(),
8200            next_fires: vec![WireAgentApprovalPreviewFire {
8201                local_time: "2026-07-21T09:00:00-04:00".to_owned(),
8202                utc_time: "2026-07-21T13:00:00Z".to_owned(),
8203                ..Default::default()
8204            }],
8205            zone_name: "America/New_York".to_owned(),
8206            zone_is_fallback: true,
8207            ..Default::default()
8208        };
8209        let list_wire = WireListApprovalPreview {
8210            prompt_text: "prompt".to_owned(),
8211            next_fires: vec![WireListApprovalPreviewFire {
8212                local_time: "2026-07-21T09:00:00-04:00".to_owned(),
8213                utc_time: "2026-07-21T13:00:00Z".to_owned(),
8214                ..Default::default()
8215            }],
8216            zone_name: "America/New_York".to_owned(),
8217            zone_is_fallback: true,
8218            ..Default::default()
8219        };
8220        assert_eq!(
8221            ApprovalPreview::from(agent_wire),
8222            ApprovalPreview::from(list_wire)
8223        );
8224    }
8225
8226    /// A recovered `PendingApprovalEntry` (no live `title` field) still
8227    /// carries the preview through to the shared [`PendingApproval`] shape.
8228    #[test]
8229    fn pending_approval_entry_conversion_carries_the_preview() {
8230        let entry = PendingApprovalEntry {
8231            turn_id: "018f47f0-5f70-7cc5-98df-123456789abc".to_owned(),
8232            request_id: "r1".to_owned(),
8233            tool_name: "routine_create".to_owned(),
8234            args_json: "{}".to_owned(),
8235            preview: buffa::MessageField::some(WireListApprovalPreview {
8236                prompt_text: "prompt".to_owned(),
8237                zone_name: "UTC".to_owned(),
8238                ..Default::default()
8239            }),
8240            ..Default::default()
8241        };
8242        let pending = PendingApproval::try_from(entry).expect("valid pending approval projection");
8243        assert_eq!(pending.turn_id, "018f47f0-5f70-7cc5-98df-123456789abc");
8244        assert_eq!(
8245            pending.preview.as_ref().map(|p| p.prompt_text.as_str()),
8246            Some("prompt")
8247        );
8248    }
8249
8250    #[test]
8251    fn pending_approval_rejects_a_present_read_preview_without_a_detail() {
8252        let entry = PendingApprovalEntry {
8253            read_preview: buffa::MessageField::some(WireListReadPreview {
8254                detail: None,
8255                __buffa_unknown_fields: buffa::UnknownFields::default(),
8256            }),
8257            ..Default::default()
8258        };
8259
8260        assert!(matches!(
8261            PendingApproval::try_from(entry),
8262            Err(DialError::InvalidApprovalRecovery(
8263                "approval read preview omitted its detail"
8264            ))
8265        ));
8266    }
8267
8268    /// `#1660`: a wire `PendingQuestion` round-trips into the client-facing
8269    /// [`PendingQuestionPrompt`] with every field carried — the question-pause
8270    /// SIBLING of `pending_approval_entry_conversion_carries_the_preview`
8271    /// above.
8272    #[test]
8273    fn pending_question_conversion_carries_every_field() {
8274        let wire = WireAgentPendingQuestion {
8275            call_id: "call-1".to_owned(),
8276            index: 0,
8277            header: "Deploy target".to_owned(),
8278            question: "Which environment?".to_owned(),
8279            options: vec![
8280                WireAgentQuestionOption {
8281                    label: "Staging".to_owned(),
8282                    description: "Deploys to staging only.".to_owned(),
8283                    recommended: false,
8284                    ..Default::default()
8285                },
8286                WireAgentQuestionOption {
8287                    label: "Production".to_owned(),
8288                    description: "Deploys straight to production.".to_owned(),
8289                    recommended: true,
8290                    ..Default::default()
8291                },
8292            ],
8293            args_json: r#"{"questions":[]}"#.to_owned(),
8294            answer_token: "token-abc".to_owned(),
8295            already_surfaced: true,
8296            ..Default::default()
8297        };
8298        let prompt = PendingQuestionPrompt::from(wire);
8299        assert_eq!(prompt.call_id, "call-1");
8300        assert_eq!(prompt.index, 0);
8301        assert_eq!(prompt.header, "Deploy target");
8302        assert_eq!(prompt.question, "Which environment?");
8303        assert_eq!(prompt.options.len(), 2);
8304        assert_eq!(prompt.options[1].label, "Production");
8305        assert!(prompt.options[1].recommended);
8306        assert_eq!(prompt.args_json, r#"{"questions":[]}"#);
8307        assert_eq!(prompt.answer_token, "token-abc");
8308        assert!(prompt.already_surfaced);
8309    }
8310
8311    /// `#1660`: the terminal `AgentEnd.pending_questions` projects into ONE
8312    /// [`TurnEvent::QuestionPending`] per entry — the question-pause SIBLING
8313    /// of `AgentEnd.pending_approvals`'s own `TurnEvent::ApprovalPending`
8314    /// projection in `events_from_end`.
8315    #[test]
8316    fn events_from_end_projects_one_question_pending_per_entry() {
8317        let end = AgentEnd {
8318            pending_questions: vec![WireAgentPendingQuestion {
8319                call_id: "call-1".to_owned(),
8320                index: 0,
8321                header: "h".to_owned(),
8322                question: "q?".to_owned(),
8323                options: vec![WireAgentQuestionOption {
8324                    label: "A".to_owned(),
8325                    ..Default::default()
8326                }],
8327                args_json: "{}".to_owned(),
8328                answer_token: "tok".to_owned(),
8329                already_surfaced: true,
8330                ..Default::default()
8331            }],
8332            ..Default::default()
8333        };
8334        let events = events_from_end(end);
8335        let question_events: Vec<&TurnEvent> = events
8336            .iter()
8337            .filter(|e| matches!(e, TurnEvent::QuestionPending { .. }))
8338            .collect();
8339        assert_eq!(question_events.len(), 1);
8340        let TurnEvent::QuestionPending {
8341            call_id,
8342            answer_token,
8343            already_surfaced,
8344            ..
8345        } = question_events[0]
8346        else {
8347            unreachable!("filtered above")
8348        };
8349        assert_eq!(call_id, "call-1");
8350        assert_eq!(answer_token, "tok");
8351        assert!(
8352            already_surfaced,
8353            "the streamed event must copy the control plane's already_surfaced bit through, \
8354             not hardcode false (unlike the approval side's streamed path)"
8355        );
8356        assert!(matches!(events.last(), Some(TurnEvent::Done)));
8357    }
8358}