polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Approvals data adapter.
//!
//! READ side: the forensics `/api/conversations/{id}/approvals` projection
//! lists pending + historical approvals.
//!
//! WRITE side (THIN path — recommended): the TUI submits an UNSIGNED
//! `ApprovalResponseRequest` via `ApprovalService.Respond`; the control plane
//! signs it server-side with its provenance key and returns the signature.
//! No operator key lives in `polyc-crypto` (its `from_seed` is dev-only),
//! so the TUI never signs. On the THIN path a `persisted` reply is, by
//! construction, validly signed; a forensics re-poll re-verifies independently.

use anyhow::{Context, Result};
use polyc_rpc_client::{ApprovalDialer, ApprovalOutcome as ApprovalRpcOutcome};
use serde::Deserialize;

use crate::action::ApprovalDecision;
use crate::components::approvals::{ApprovalOutcome, ApprovalView};

/// Serde mirror of the forensics `ApprovalsResponse` (`snake_case` keys).
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ApprovalsResponse {
    /// The conversation id echoed back.
    pub conversation_id: String,
    /// All approvals (pending + decided) for the conversation.
    pub approvals: Vec<ApprovalEntry>,
}

/// Serde mirror of the forensics `ApprovalEntry`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ApprovalEntry {
    /// Turn that emitted this occurrence of `request_id`.
    pub turn_id: String,
    /// The pending tool-call id (== `request_id`).
    pub request_id: String,
    /// The tool/function name awaiting approval.
    pub tool_name: String,
    /// The arguments JSON for the call.
    pub args_json: String,
    /// The recorded response, if a decision has been persisted.
    pub response: Option<ApprovalResponseEntry>,
    /// Short-lived signed capability (`#787`), freshly minted for a still-
    /// pending entry (empty for an already-decided one); required by
    /// `ApprovalService.Respond`.
    #[serde(default)]
    pub resolve_token: String,
}

/// Serde mirror of the forensics `ApprovalResponseEntry`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ApprovalResponseEntry {
    /// Whether the call was authorised.
    pub approved: bool,
    /// The recorded rationale.
    pub reason: String,
    /// Lowercase-hex signing public key.
    pub signer_pk_hex: String,
    /// Lowercase-hex ed25519 signature.
    pub sig_hex: String,
    /// Whether the recorded signature verifies.
    pub signature_valid: bool,
}

/// Load the approvals for `conversation_id` from the forensics server,
/// projected to [`ApprovalView`]s.
///
/// # Errors
/// Returns an error if the HTTP request fails or the body cannot be decoded.
pub(crate) async fn load_approvals(
    forensics_base_url: &str,
    conversation_id: &str,
) -> Result<Vec<ApprovalView>> {
    let url = format!("{forensics_base_url}/api/conversations/{conversation_id}/approvals");
    let body: ApprovalsResponse = crate::data::http_client()
        .get(&url)
        .send()
        .await
        .with_context(|| format!("GET {url}"))?
        .error_for_status()
        .with_context(|| format!("approvals projection HTTP error for {conversation_id}"))?
        .json()
        .await
        .with_context(|| format!("decode ApprovalsResponse for {conversation_id}"))?;
    // The forensics projection echoes the conversation id; prefer it so the
    // view's `conversation_id` matches the recorded partition even if the
    // caller passed a differently-cased id.
    let conv_id = if body.conversation_id.is_empty() {
        conversation_id
    } else {
        &body.conversation_id
    };
    Ok(body
        .approvals
        .iter()
        .map(|entry| view_from_entry(conv_id, entry))
        .collect())
}

/// Submit an approval decision through the THIN path: dial
/// `ApprovalService.Respond` at `agent_addr` via the shared
/// [`ApprovalDialer`] (the single home for that Connect dance, reused from the
/// operator CLI / an edge receiver) and return the persisted outcome (the control
/// plane is the signer — we submit an UNSIGNED decision and it returns the
/// server-signed result).
///
/// # Errors
/// Returns an error if the address is invalid or the RPC fails.
pub(crate) async fn submit_decision(
    agent_addr: &str,
    decision: &ApprovalDecision,
) -> Result<ApprovalRpcOutcome> {
    // The operator cockpit answers a single call: a plain approve or deny.
    // "Remember for the session", "abort", edited args, and injected context are
    // edge/chat affordances, not TUI ones.
    let choice = if decision.approved {
        polyc_rpc_client::ApprovalChoice::Approve
    } else {
        polyc_rpc_client::ApprovalChoice::Deny
    };
    let outcome = ApprovalDialer::new(agent_addr)
        .with_context(|| format!("invalid agent address {agent_addr:?}"))?
        .respond(
            &decision.turn_id,
            &decision.request_id,
            choice,
            &decision.reason,
            &decision.conversation_id,
            "",
            "",
            &decision.resolve_token,
            None,
        )
        .await
        .with_context(|| format!("ApprovalService.Respond at {agent_addr}"))?;
    Ok(outcome)
}

/// Project a forensics [`ApprovalEntry`] into an [`ApprovalView`] for a
/// conversation.
#[must_use]
pub(crate) fn view_from_entry(conversation_id: &str, entry: &ApprovalEntry) -> ApprovalView {
    ApprovalView {
        turn_id: entry.turn_id.clone(),
        conversation_id: conversation_id.to_owned(),
        request_id: entry.request_id.clone(),
        tool_name: entry.tool_name.clone(),
        args_json: entry.args_json.clone(),
        resolve_token: entry.resolve_token.clone(),
        response: entry.response.as_ref().map(|r| ApprovalOutcome {
            approved: r.approved,
            reason: r.reason.clone(),
            signer_pk_hex: r.signer_pk_hex.clone(),
            signature_hex: r.sig_hex.clone(),
            signature_valid: r.signature_valid,
        }),
    }
}