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}