polyc-rpc-client 0.1.3

Thin connectrpc client over the generated AgentServiceClient, shared by the CLI and Slack receiver.
Documentation
//! The edge/adapter contract.
//!
//! Every surface that puts the outside world in front of a polychrome
//! conversation — a chat app, a web UI, a code-forge bot, an inbound-mail handler,
//! an MCP server, a cron trigger — is an *edge*. The design
//! (`docs/design/public-api-and-edges.md` §3) defines a single thin contract so
//! edges are interchangeable and vendor-agnostic: if an adapter satisfies it,
//! it works, regardless of which product or protocol it wraps.
//!
//! ## The five concerns
//!
//! The contract names five concerns. Two of them — **identity/namespacing** and
//! **ingress** — vary per edge and are pure mappings, so they are the methods of
//! the [`EdgeAdapter`] trait. The other three are satisfied by composing this
//! crate's transport, identically across edges, so they are documented here
//! rather than forced into awkward per-edge trait methods:
//!
//! 1. **Identity & namespacing** — [`EdgeAdapter::namespace`] +
//!    [`EdgeAdapter::conversation_id`], built on [`crate::namespaced_id`] /
//!    [`crate::hashed_conversation_id`].
//! 2. **Ingress** — [`EdgeAdapter::to_turn_input`] turns one native inbound unit
//!    into turn-input [`TurnMessage`]s. Any I/O the edge needs first (resolving
//!    display names, fetching a thread) happens before this pure mapping.
//! 3. **Egress / streaming** — drive [`crate::AgentDialer::run_turn_streaming_messages`]
//!    with the [`TurnMessage`]s and render the [`crate::TurnEvent`] stream where
//!    the transport allows incremental output.
//! 4. **Approval & handoff hooks** — react to [`crate::TurnEvent::ApprovalPending`]
//!    and [`crate::TurnEvent::HandoffStarted`] from that same stream, and answer
//!    approvals out-of-band via [`crate::ApprovalDialer`].
//! 5. **Auth & trust boundary** — authenticate the caller at the edge's own
//!    transport (a chat edge verifies an HMAC; a webhook checks a secret; the public
//!    HTTP surface will check a bearer token) and carry that identity inward.
//!    This stays edge-native because the mechanism differs fundamentally per
//!    transport; the contract only requires that it happens before ingress.
//!
//! `polychrome-slack` is the reference implementation.

use crate::{Attribution, ExternalIdentity, TurnMessage};

/// The contract an edge implements to ride the public Connect API.
///
/// The trait is deliberately small: it captures only the per-edge *mapping*
/// decisions (how a native addressing unit becomes a namespaced conversation
/// id, and how a native inbound unit becomes turn input). Transport — opening
/// the turn stream, rendering deltas, answering approvals — is provided by
/// [`crate::AgentDialer`] / [`crate::ApprovalDialer`] and is the same for every
/// edge, so it is not part of the trait (see the module docs).
pub trait EdgeAdapter {
    /// This edge's native addressing unit — whatever it derives a conversation
    /// from. Slack: a `(team, channel, thread)` coordinate; a code-forge edge:
    /// an `(owner, repo, issue)` ref; a web UI: a session id.
    type Native;

    /// One native inbound unit that [`Self::to_turn_input`] maps to turn input.
    /// Slack: one attributed thread line; a web UI: one request message. The
    /// edge resolves any I/O (display names, thread fetch) into this value
    /// *before* the pure mapping.
    type Inbound;

    /// The namespace prefix this edge stamps onto conversation ids
    /// (`"slack"`, `"github"`, `"web"`, `"mcp"`, …). Used to keep ids greppable
    /// and to route forensics by edge family.
    fn namespace(&self) -> &'static str;

    /// Derive the namespaced [`AgentRequest`](crate::AgentDialer) conversation
    /// id for a native unit. Implementations build it from
    /// [`crate::namespaced_id`] (readable) or [`crate::hashed_conversation_id`]
    /// (fixed-length opaque), keeping [`Self::namespace`] as the prefix /
    /// pinned-namespace policy.
    fn conversation_id(&self, native: &Self::Native) -> String;

    /// Map one inbound unit to zero-or-more turn-input messages. An empty
    /// result drops the unit (e.g. an empty or self-authored message). Pure:
    /// no I/O — the edge does any lookups before calling this, so the mapping
    /// is unit-testable in isolation.
    fn to_turn_input(&self, inbound: &Self::Inbound) -> Vec<TurnMessage>;

    /// The external identity of the human behind one inbound unit, when the
    /// edge knows it — the second pure *identity* mapping next to
    /// [`Self::conversation_id`] (docs/design/personas.md §4). The tuple must
    /// use the provider's *stable* id with its disambiguating scope (a chat
    /// workspace id), never a mutable handle. `None` (the default) means this
    /// unit carries no identity; an edge that returns `None` for every unit
    /// (and sends no participants) is attribution-free.
    fn caller(&self, _inbound: &Self::Inbound) -> Option<ExternalIdentity> {
        None
    }
}

/// Assemble a turn's [`Attribution`] from an edge's inbound units.
///
/// The triggering unit's identity becomes the caller; every *other* distinct
/// identity among `observed` becomes a participant. Pass as `observed` only
/// the units whose content actually enters the turn's input — attribution
/// must not record speakers the turn never saw. Deduplication is by
/// `(provider, scope, external_id)` — display names don't identify.
///
/// SDK-composed (not a trait method) so every edge shares one dedupe policy;
/// the per-edge part is exactly [`EdgeAdapter::caller`].
pub fn build_attribution<E: EdgeAdapter>(
    edge: &E,
    trigger: Option<&E::Inbound>,
    observed: &[E::Inbound],
) -> Attribution {
    // Identity equality is the (provider, scope, external_id) tuple —
    // display names don't identify.
    fn same(a: &ExternalIdentity, b: &ExternalIdentity) -> bool {
        a.provider == b.provider && a.scope == b.scope && a.external_id == b.external_id
    }

    let caller = trigger.and_then(|t| edge.caller(t));

    let mut participants: Vec<ExternalIdentity> = Vec::new();
    for unit in observed {
        let Some(identity) = edge.caller(unit) else {
            continue;
        };
        let duplicate = caller.as_ref().is_some_and(|c| same(c, &identity))
            || participants.iter().any(|p| same(p, &identity));
        if !duplicate {
            participants.push(identity);
        }
    }

    Attribution {
        caller,
        participants,
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;
    use crate::{attributed_message, hashed_conversation_id};

    // A minimal reference edge proving the contract is implementable and pure.
    struct ExampleEdge {
        namespace_uuid: uuid::Uuid,
    }

    struct Thread {
        team: String,
        channel: String,
        thread_ts: String,
    }

    struct Line {
        speaker: String,
        text: String,
    }

    impl EdgeAdapter for ExampleEdge {
        type Native = Thread;
        type Inbound = Line;

        fn namespace(&self) -> &'static str {
            "example"
        }

        fn conversation_id(&self, native: &Thread) -> String {
            hashed_conversation_id(
                self.namespace_uuid,
                &[&native.team, &native.channel, &native.thread_ts],
            )
        }

        fn to_turn_input(&self, inbound: &Line) -> Vec<TurnMessage> {
            if inbound.text.trim().is_empty() {
                return Vec::new();
            }
            vec![attributed_message(&inbound.speaker, &inbound.text)]
        }
    }

    #[test]
    fn conversation_id_is_stable_per_native_unit() {
        let edge = ExampleEdge {
            namespace_uuid: uuid::Uuid::from_u128(0x42),
        };
        let t = Thread {
            team: "T1".to_owned(),
            channel: "C1".to_owned(),
            thread_ts: "169.0".to_owned(),
        };
        assert_eq!(edge.conversation_id(&t), edge.conversation_id(&t));
        assert_eq!(edge.namespace(), "example");
    }

    #[test]
    fn ingress_drops_empty_and_attributes_speakers() {
        let edge = ExampleEdge {
            namespace_uuid: uuid::Uuid::from_u128(0x42),
        };
        assert!(
            edge.to_turn_input(&Line {
                speaker: "Alice".to_owned(),
                text: "   ".to_owned(),
            })
            .is_empty()
        );
        assert_eq!(
            edge.to_turn_input(&Line {
                speaker: "Alice".to_owned(),
                text: "hi".to_owned(),
            }),
            vec![attributed_message("Alice", "hi")]
        );
    }
}