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}