Skip to main content

polyc_rpc_client/
edge.rs

1//! The edge/adapter contract.
2//!
3//! Every surface that puts the outside world in front of a polychrome
4//! conversation — a chat app, a web UI, an inbound-mail handler, an MCP
5//! server, a cron trigger — is an *edge*. The design
6//! (`docs/reference/public-api-and-edges.md` §3) defines a single thin contract so
7//! edges are interchangeable and vendor-agnostic: if an adapter satisfies it,
8//! it works, regardless of which product or protocol it wraps.
9//!
10//! ## The five concerns
11//!
12//! The contract names five concerns. Two of them — **identity/namespacing** and
13//! **ingress** — vary per edge and are pure mappings, so they are the methods of
14//! the [`EdgeAdapter`] trait. The other three are satisfied by composing this
15//! crate's transport, identically across edges, so they are documented here
16//! rather than forced into awkward per-edge trait methods:
17//!
18//! 1. **Identity & namespacing** — [`EdgeAdapter::namespace`] +
19//!    [`EdgeAdapter::conversation_id`], built on [`crate::namespaced_id`] /
20//!    [`crate::hashed_conversation_id`].
21//! 2. **Ingress** — [`EdgeAdapter::to_turn_input`] turns one native inbound unit
22//!    into turn-input [`TurnMessage`]s. After authenticating the transport
23//!    envelope, the handler separately constructs the required
24//!    [`IngressIdentity`] from that envelope's source coordinate. It is not an
25//!    `EdgeAdapter` method because `Inbound` deliberately contains mapped
26//!    content, not provider delivery metadata. Any I/O the edge needs first
27//!    (resolving display names, fetching a thread) happens before this pure
28//!    mapping.
29//! 3. **Egress / streaming** — drive [`crate::AgentDialer::run_turn_streaming_messages`]
30//!    with the [`TurnMessage`]s and render the [`crate::TurnEvent`] stream where
31//!    the transport allows incremental output.
32//! 4. **Approval & handoff hooks** — react to [`crate::TurnEvent::ApprovalPending`]
33//!    and [`crate::TurnEvent::HandoffStarted`] from that same stream, and answer
34//!    approvals out-of-band via [`crate::ApprovalDialer`].
35//! 5. **Auth & trust boundary** — authenticate the caller at the edge's own
36//!    transport (a chat edge verifies an HMAC; a webhook checks a secret; the public
37//!    HTTP surface will check a bearer token) and carry that identity inward.
38//!    This stays edge-native because the mechanism differs fundamentally per
39//!    transport; the contract only requires that it happens before ingress.
40//!
41//! `polychrome-slack` is the reference implementation.
42
43use crate::{Attribution, ExternalIdentity, TurnMessage};
44use polyc_proto::proto::polychrome::agent::v1::{
45    IngressSourceIdentity as WireIngressSourceIdentity, ingress_source_identity,
46};
47
48/// Maximum bytes in a claimed tenancy namespace.
49///
50/// The stored grant side bounds an entry at 256 bytes, because an
51/// administrator configures that set and it holds arbitrary tenancy names. A
52/// claim is caller-supplied and drawn from a fixed vocabulary whose longest
53/// member is eight bytes, so it takes the tighter bound. The value lands in a
54/// partition name, a log field, and a query column; short and greppable is
55/// worth more than the range it refuses.
56pub const MAX_CLAIMED_NAMESPACE_BYTES: usize = 64;
57
58/// Errors constructing a claimed tenancy namespace.
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60pub enum ClaimedNamespaceError {
61    /// The claim was empty.
62    #[error("a claimed conversation namespace must not be empty")]
63    Empty,
64    /// The claim was longer than [`MAX_CLAIMED_NAMESPACE_BYTES`].
65    #[error(
66        "a claimed conversation namespace is 1..={MAX_CLAIMED_NAMESPACE_BYTES} bytes, got {actual}"
67    )]
68    TooLong {
69        /// The rejected length, in bytes.
70        actual: usize,
71    },
72    /// The claim held a byte outside `[a-z0-9]`.
73    #[error("a claimed conversation namespace holds lowercase ASCII letters and digits only")]
74    InvalidCharacter,
75}
76
77/// The tenancy namespace a turn claims for its conversation (#1691).
78///
79/// The control plane refuses a claim outside the asserting credential's
80/// `allowed_namespaces`, and binds the conversation to this value on its first
81/// dispatch. This type has one fallible constructor and no empty case, which
82/// is what makes "a conversation cannot hold a dispatched turn and no
83/// namespace" true by construction rather than by convention (INV-N5).
84///
85/// This is deliberately narrower than what the state plane accepts in a stored
86/// grant. A grant is administrator-controlled housekeeping; a claim is the
87/// caller-adversarial boundary. The wildcard `"*"` is a legal grant entry and
88/// is never a legal claim — it is a sentinel meaning "any namespace", and no
89/// conversation belongs to it.
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91pub struct ClaimedNamespace(String);
92
93impl ClaimedNamespace {
94    /// Validates a claimed tenancy namespace.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`ClaimedNamespaceError`] when the claim is empty, longer than
99    /// [`MAX_CLAIMED_NAMESPACE_BYTES`], or holds a byte outside `[a-z0-9]`.
100    /// The wildcard `"*"` fails the character rule.
101    pub fn new(value: impl Into<String>) -> Result<Self, ClaimedNamespaceError> {
102        let value = value.into();
103        if value.is_empty() {
104            return Err(ClaimedNamespaceError::Empty);
105        }
106        if value.len() > MAX_CLAIMED_NAMESPACE_BYTES {
107            return Err(ClaimedNamespaceError::TooLong {
108                actual: value.len(),
109            });
110        }
111        if !value
112            .bytes()
113            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
114        {
115            return Err(ClaimedNamespaceError::InvalidCharacter);
116        }
117        Ok(Self(value))
118    }
119
120    /// Returns the claimed namespace.
121    #[must_use]
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127impl std::fmt::Display for ClaimedNamespace {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(&self.0)
130    }
131}
132
133/// Errors constructing a stable ingress identity.
134#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
135pub enum IngressIdentityError {
136    /// The source namespace was empty.
137    #[error("ingress source namespace must not be empty")]
138    EmptyNamespace,
139    /// A source-reported event identifier was empty.
140    #[error("reported ingress event id must not be empty")]
141    EmptyReportedId,
142}
143
144/// Stable identity one source event keeps across every redelivery.
145///
146/// The namespace and event identifier come from the authenticated source
147/// protocol. Neither an execution id nor an envelope nonce can construct this
148/// type, because both change between delivery attempts.
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct IngressIdentity {
151    namespace: String,
152    event: IngressEventId,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Hash)]
156enum IngressEventId {
157    Reported(String),
158    Derived([u8; 32]),
159}
160
161impl IngressIdentity {
162    /// Builds an identity from an event identifier reported by the source.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`IngressIdentityError::EmptyNamespace`] or
167    /// [`IngressIdentityError::EmptyReportedId`] when either required part is
168    /// empty.
169    pub fn reported(
170        namespace: impl Into<String>,
171        event_id: impl Into<String>,
172    ) -> Result<Self, IngressIdentityError> {
173        let namespace = namespace.into();
174        if namespace.is_empty() {
175            return Err(IngressIdentityError::EmptyNamespace);
176        }
177        let event_id = event_id.into();
178        if event_id.is_empty() {
179            return Err(IngressIdentityError::EmptyReportedId);
180        }
181        Ok(Self {
182            namespace,
183            event: IngressEventId::Reported(event_id),
184        })
185    }
186
187    /// Builds a reported identity from an ordered composite source key.
188    ///
189    /// Each component is length-framed, so values containing delimiters
190    /// cannot collide with a different component split.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`IngressIdentityError::EmptyNamespace`] when `namespace` is
195    /// empty, or [`IngressIdentityError::EmptyReportedId`] when `components`
196    /// is empty or contains an empty component.
197    pub fn reported_components(
198        namespace: impl Into<String>,
199        components: &[&str],
200    ) -> Result<Self, IngressIdentityError> {
201        if components.is_empty() || components.iter().any(|part| part.is_empty()) {
202            return Err(IngressIdentityError::EmptyReportedId);
203        }
204        let mut framed = String::new();
205        for part in components {
206            framed.push_str(&part.len().to_string());
207            framed.push(':');
208            framed.push_str(part);
209            framed.push('/');
210        }
211        Self::reported(namespace, framed)
212    }
213
214    /// Builds an identity derived from the SHA-256 digest of authenticated
215    /// source fields when that source genuinely reports no event identifier.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`IngressIdentityError::EmptyNamespace`] when `namespace` is
220    /// empty.
221    pub fn derived(
222        namespace: impl Into<String>,
223        authenticated_fields_digest: [u8; 32],
224    ) -> Result<Self, IngressIdentityError> {
225        let namespace = namespace.into();
226        if namespace.is_empty() {
227            return Err(IngressIdentityError::EmptyNamespace);
228        }
229        Ok(Self {
230            namespace,
231            event: IngressEventId::Derived(authenticated_fields_digest),
232        })
233    }
234
235    /// Returns the edge-native namespace in which the event id is unique.
236    #[must_use]
237    pub fn namespace(&self) -> &str {
238        &self.namespace
239    }
240
241    pub(crate) fn to_wire(&self) -> WireIngressSourceIdentity {
242        let event_id = match &self.event {
243            IngressEventId::Reported(id) => {
244                ingress_source_identity::EventId::ReportedId(id.clone())
245            }
246            IngressEventId::Derived(digest) => {
247                ingress_source_identity::EventId::DerivedDigest(digest.to_vec())
248            }
249        };
250        WireIngressSourceIdentity {
251            namespace: self.namespace.clone(),
252            event_id: Some(event_id),
253            ..Default::default()
254        }
255    }
256}
257
258/// Advisory scheduling weight on an [`IngressDirective`]. Re-exported from the
259/// wire type — no scheduler exists today (dispatch is immediate per
260/// conversation-lease), so this value is only ever recorded (a signed
261/// `ingress_directive` eventlog event, for forensics/operator display), never
262/// used to reorder or defer a turn.
263pub use polyc_proto::proto::polychrome::agent::v1::Priority;
264
265/// Edge-authored policy for one turn (`#68`).
266///
267/// The edge's own decision, never something the model or a tool can set.
268/// Built by [`EdgeAdapter::ingress_directive`]; threaded through
269/// [`crate::AgentDialer`]'s dialer entry points into the wire `AgentStart`.
270/// Every field absent (the [`Default`]) is the common case and costs nothing
271/// on the wire — [`Self::is_empty`] says so without inspecting each field.
272#[derive(Debug, Clone, Default, PartialEq)]
273pub struct IngressDirective {
274    /// Upper bound on the turn's step budget. The control plane can only
275    /// LOWER the budget it would otherwise resolve, never raise it. `None`
276    /// means the edge expresses no cap.
277    pub budget_cap: Option<u32>,
278    /// Advisory scheduling hint; recorded in the eventlog, not scheduled (see
279    /// [`Priority`]). `None` means the edge expressed no preference.
280    pub priority: Option<Priority>,
281    /// The identity that alone may APPROVE a gated call this turn (enforced
282    /// fail-closed in `ApprovalService.Respond`); Deny/Defer stay open to any
283    /// resolve-token holder. `None` means no approver restriction.
284    pub required_approver: Option<ExternalIdentity>,
285}
286
287impl IngressDirective {
288    /// True when every field is unset — the common case for an edge with no
289    /// ingress policy, and the signal the control plane uses to skip
290    /// appending an `ingress_directive` eventlog event for this turn.
291    #[must_use]
292    pub const fn is_empty(&self) -> bool {
293        self.budget_cap.is_none() && self.priority.is_none() && self.required_approver.is_none()
294    }
295}
296
297/// The contract an edge implements to ride the public Connect API.
298///
299/// The trait is deliberately small: it captures only the per-edge *mapping*
300/// decisions (how a native addressing unit becomes a namespaced conversation
301/// id, and how a native inbound unit becomes turn input). Transport — opening
302/// the turn stream, rendering deltas, answering approvals — is provided by
303/// [`crate::AgentDialer`] / [`crate::ApprovalDialer`] and is the same for every
304/// edge, so it is not part of the trait (see the module docs).
305pub trait EdgeAdapter {
306    /// This edge's native addressing unit — whatever it derives a conversation
307    /// from. Slack: a `(team, channel, thread)` coordinate; an inbound-mail
308    /// handler: a `(mailbox, thread)` pair; a web UI: a session id.
309    type Native;
310
311    /// One native inbound unit that [`Self::to_turn_input`] maps to turn input.
312    /// Slack: one attributed thread line; a web UI: one request message. The
313    /// edge resolves any I/O (display names, thread fetch) into this value
314    /// *before* the pure mapping.
315    type Inbound;
316
317    /// The namespace prefix this edge stamps onto conversation ids, and the
318    /// tenancy namespace it claims. Used to keep ids greppable, to route
319    /// forensics by edge family, and to authorize the write (#1691).
320    ///
321    /// The live values are `slack`, `telegram`, `discord`, `email`, and `evt`.
322    /// Note `email`, not `mail`: an operator granting the wrong spelling
323    /// refuses every turn from that edge, and this doc comment is where a new
324    /// edge author reads the convention.
325    ///
326    /// This value is also the edge's tenancy claim: see
327    /// [`Self::claimed_namespace`]. Return the same string the operator grants
328    /// in `allowed_namespaces`, or narrowing that grant refuses every turn
329    /// this edge sends.
330    fn namespace(&self) -> &'static str;
331
332    /// The tenancy namespace this edge claims on every turn it dials (#1691).
333    ///
334    /// Derived from [`Self::namespace`] so the greppable id prefix and the
335    /// authorization claim cannot drift apart. An edge does not override this.
336    ///
337    /// # Panics
338    ///
339    /// Panics when [`Self::namespace`] returns a value
340    /// [`ClaimedNamespace`] refuses. Every namespace is a compile-time
341    /// constant, so a panic here means a new edge shipped one no operator can
342    /// ever grant. Failing loudly at startup beats refusing that edge's every
343    /// turn.
344    ///
345    /// This crate cannot prove that for a given edge: the implementors live
346    /// in downstream crates, so `every_real_namespace_is_a_valid_claim` below
347    /// checks a hand-written list and cannot catch a NEW edge. Each edge
348    /// crate carries its own `the_namespace_is_a_valid_claim` test against
349    /// its own adapter, which is the only place that check is real.
350    #[must_use]
351    fn claimed_namespace(&self) -> ClaimedNamespace {
352        ClaimedNamespace::new(self.namespace()).expect("an edge's namespace is a valid claim")
353    }
354
355    /// Derive the namespaced [`AgentRequest`](crate::AgentDialer) conversation
356    /// id for a native unit. Implementations build it from
357    /// [`crate::namespaced_id`] (readable) or [`crate::hashed_conversation_id`]
358    /// (fixed-length opaque), keeping [`Self::namespace`] as the prefix /
359    /// pinned-namespace policy.
360    fn conversation_id(&self, native: &Self::Native) -> String;
361
362    /// Map one inbound unit to zero-or-more turn-input messages. An empty
363    /// result drops the unit (e.g. an empty or self-authored message). Pure:
364    /// no I/O — the edge does any lookups before calling this, so the mapping
365    /// is unit-testable in isolation.
366    fn to_turn_input(&self, inbound: &Self::Inbound) -> Vec<TurnMessage>;
367
368    /// The external identity of the human behind one inbound unit, when the
369    /// edge knows it — the second pure *identity* mapping next to
370    /// [`Self::conversation_id`] (docs/reference/personas.md §4). The tuple must
371    /// use the provider's *stable* id with its disambiguating scope (a chat
372    /// workspace id), never a mutable handle. `None` (the default) means this
373    /// unit carries no identity; an edge that returns `None` for every unit
374    /// (and sends no participants) is attribution-free.
375    fn caller(&self, _inbound: &Self::Inbound) -> Option<ExternalIdentity> {
376        None
377    }
378
379    /// This edge's ingress policy for one inbound unit (`#68`) — a step-budget
380    /// cap, an advisory priority, and/or a required approver, attached to the
381    /// event that starts (or resumes) the turn. Edge-authored, never derived
382    /// from message content: an edge that has no such policy leaves the
383    /// default, which is empty ([`IngressDirective::is_empty`]) and costs
384    /// nothing downstream — every existing edge compiles unchanged.
385    fn ingress_directive(&self, _inbound: &Self::Inbound) -> IngressDirective {
386        IngressDirective::default()
387    }
388}
389
390/// Assemble a turn's [`Attribution`] from an edge's inbound units.
391///
392/// The triggering unit's identity becomes the caller; every *other* distinct
393/// identity among `observed` becomes a participant. Pass as `observed` only
394/// the units whose content actually enters the turn's input — attribution
395/// must not record speakers the turn never saw. Deduplication is by
396/// `(provider, scope, external_id)` — display names don't identify.
397///
398/// SDK-composed (not a trait method) so every edge shares one dedupe policy;
399/// the per-edge part is exactly [`EdgeAdapter::caller`].
400pub fn build_attribution<E: EdgeAdapter>(
401    edge: &E,
402    trigger: Option<&E::Inbound>,
403    observed: &[E::Inbound],
404) -> Attribution {
405    // Identity equality is the (provider, scope, external_id) tuple —
406    // display names don't identify.
407    fn same(a: &ExternalIdentity, b: &ExternalIdentity) -> bool {
408        a.provider == b.provider && a.scope == b.scope && a.external_id == b.external_id
409    }
410
411    let caller = trigger.and_then(|t| edge.caller(t));
412
413    let mut participants: Vec<ExternalIdentity> = Vec::new();
414    for unit in observed {
415        let Some(identity) = edge.caller(unit) else {
416            continue;
417        };
418        let duplicate = caller.as_ref().is_some_and(|c| same(c, &identity))
419            || participants.iter().any(|p| same(p, &identity));
420        if !duplicate {
421            participants.push(identity);
422        }
423    }
424
425    Attribution {
426        caller,
427        participants,
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
434    use super::*;
435    use crate::{attributed_message, hashed_conversation_id};
436
437    /// Every namespace a real surface claims is accepted. The list is the
438    /// vocabulary in ADR 0011's minter table; a rule that refused one of these
439    /// would refuse that surface's every turn.
440    #[test]
441    fn every_real_namespace_is_a_valid_claim() {
442        for namespace in [
443            "slack", "telegram", "discord", "email", "evt", "a2a", "web", "app", "mcp", "routine",
444            "eval", "cli",
445        ] {
446            assert_eq!(
447                ClaimedNamespace::new(namespace)
448                    .expect("a real namespace is a valid claim")
449                    .as_str(),
450                namespace,
451            );
452        }
453    }
454
455    /// The wildcard is a grant sentinel, never a claim. A conversation cannot
456    /// belong to "any namespace", and every credential deployed today holds
457    /// the wildcard — so an unconstrained claim would let any of them bind a
458    /// conversation to it permanently, with no adoption path.
459    #[test]
460    fn the_wildcard_is_not_a_valid_claim() {
461        assert_eq!(
462            ClaimedNamespace::new("*"),
463            Err(ClaimedNamespaceError::InvalidCharacter),
464        );
465    }
466
467    #[test]
468    fn an_empty_claim_is_refused() {
469        assert_eq!(ClaimedNamespace::new(""), Err(ClaimedNamespaceError::Empty));
470    }
471
472    /// A colon would split under any namespaced-id reader, and the stored
473    /// grant side refuses one for the same reason.
474    #[test]
475    fn a_claim_holding_a_colon_is_refused() {
476        assert_eq!(
477            ClaimedNamespace::new("slack:team"),
478            Err(ClaimedNamespaceError::InvalidCharacter),
479        );
480    }
481
482    #[test]
483    fn a_claim_is_lowercase_ascii_only() {
484        for rejected in ["Slack", "web-chat", "web_chat", "café", "web.chat", " web"] {
485            assert_eq!(
486                ClaimedNamespace::new(rejected),
487                Err(ClaimedNamespaceError::InvalidCharacter),
488                "{rejected} must not be a valid claim",
489            );
490        }
491    }
492
493    #[test]
494    fn a_claim_is_bounded() {
495        let longest = "a".repeat(MAX_CLAIMED_NAMESPACE_BYTES);
496        assert!(ClaimedNamespace::new(longest).is_ok());
497        let over = "a".repeat(MAX_CLAIMED_NAMESPACE_BYTES + 1);
498        assert_eq!(
499            ClaimedNamespace::new(over),
500            Err(ClaimedNamespaceError::TooLong {
501                actual: MAX_CLAIMED_NAMESPACE_BYTES + 1,
502            }),
503        );
504    }
505
506    #[test]
507    fn source_identity_is_stable_when_execution_identity_changes() {
508        let identity = crate::IngressIdentity::reported("workspace-1", "message-42")
509            .expect("reported source identity is valid");
510
511        let first = identity.to_wire();
512        let second = identity.to_wire();
513        assert_eq!(first, second);
514        assert_eq!(identity.namespace(), "workspace-1");
515    }
516
517    #[test]
518    fn composite_reported_identity_is_injective() {
519        let left =
520            IngressIdentity::reported_components("source", &["a/b", "c"]).expect("valid identity");
521        let right =
522            IngressIdentity::reported_components("source", &["a", "b/c"]).expect("valid identity");
523        assert_ne!(left, right);
524    }
525
526    // A minimal reference edge proving the contract is implementable and pure.
527    struct ExampleEdge {
528        namespace_uuid: uuid::Uuid,
529    }
530
531    struct Thread {
532        team: String,
533        channel: String,
534        thread_ts: String,
535    }
536
537    struct Line {
538        speaker: String,
539        text: String,
540    }
541
542    impl EdgeAdapter for ExampleEdge {
543        type Native = Thread;
544        type Inbound = Line;
545
546        fn namespace(&self) -> &'static str {
547            "example"
548        }
549
550        fn conversation_id(&self, native: &Thread) -> String {
551            hashed_conversation_id(
552                self.namespace_uuid,
553                &[&native.team, &native.channel, &native.thread_ts],
554            )
555        }
556
557        fn to_turn_input(&self, inbound: &Line) -> Vec<TurnMessage> {
558            if inbound.text.trim().is_empty() {
559                return Vec::new();
560            }
561            vec![attributed_message(&inbound.speaker, &inbound.text)]
562        }
563    }
564
565    #[test]
566    fn conversation_id_is_stable_per_native_unit() {
567        let edge = ExampleEdge {
568            namespace_uuid: uuid::Uuid::from_u128(0x42),
569        };
570        let t = Thread {
571            team: "T1".to_owned(),
572            channel: "C1".to_owned(),
573            thread_ts: "169.0".to_owned(),
574        };
575        assert_eq!(edge.conversation_id(&t), edge.conversation_id(&t));
576        assert_eq!(edge.namespace(), "example");
577    }
578
579    #[test]
580    fn ingress_drops_empty_and_attributes_speakers() {
581        let edge = ExampleEdge {
582            namespace_uuid: uuid::Uuid::from_u128(0x42),
583        };
584        assert!(
585            edge.to_turn_input(&Line {
586                speaker: "Alice".to_owned(),
587                text: "   ".to_owned(),
588            })
589            .is_empty()
590        );
591        assert_eq!(
592            edge.to_turn_input(&Line {
593                speaker: "Alice".to_owned(),
594                text: "hi".to_owned(),
595            }),
596            vec![attributed_message("Alice", "hi")]
597        );
598    }
599}