Skip to main content

subc_protocol/
scope.rs

1//! Scope records: owned identity records the daemon holds for sessions.
2//!
3//! A scope is identified by `(owner, ref)`. The owner is the module whose own
4//! registered connection synced it, never a value in the request, and the
5//! `ref` is an opaque string unique within that owner only. The design is
6//! `docs/designs/daemon-scopes.md`; the wire shapes here are the owner-facing
7//! half of it (`scope.sync`, `scope.apply`, `scope.describe`).
8//!
9//! Every record type refuses unknown fields. A field this daemon does not know
10//! may be one that narrows authority in a later version (a carrier's target
11//! list, say), and silently dropping it would widen what the scope grants, so
12//! an owner sending one is told its body is malformed instead.
13
14use serde::{Deserialize, Serialize};
15
16use crate::Principal;
17
18// Scope principals are authority-bearing input: an unrecognized constraint must
19// not silently widen a grant. Principal elsewhere is a forward-compatible caller
20// fact, so keep its general decoder lenient and enforce this only on scope input.
21#[derive(Deserialize)]
22#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
23enum ScopePrincipal {
24    Reserved { module_id: String },
25    Direct {},
26    Unverified {},
27}
28
29impl From<ScopePrincipal> for Principal {
30    fn from(value: ScopePrincipal) -> Self {
31        match value {
32            ScopePrincipal::Reserved { module_id } => Self::Reserved { module_id },
33            ScopePrincipal::Direct {} => Self::Direct,
34            ScopePrincipal::Unverified {} => Self::Unverified,
35        }
36    }
37}
38
39fn deserialize_scope_principal<'de, D: serde::Deserializer<'de>>(
40    deserializer: D,
41) -> Result<Principal, D::Error> {
42    ScopePrincipal::deserialize(deserializer).map(Into::into)
43}
44
45fn deserialize_scope_principals<'de, D: serde::Deserializer<'de>>(
46    deserializer: D,
47) -> Result<Vec<Principal>, D::Error> {
48    Vec::<ScopePrincipal>::deserialize(deserializer)
49        .map(|principals| principals.into_iter().map(Into::into).collect())
50}
51
52/// The `server.describe` capability a daemon advertises when it admits routes
53/// under scopes. A carrier that needs a scoped route and does not see it fails
54/// the call (`scope_unsupported`) instead of opening an unscoped route.
55pub const CAP_SCOPES_V1: &str = "scopes/v1";
56
57/// The `server.describe` capability a daemon advertises when it checks a
58/// `route.open`'s `role_versions` and forwards them on the module's bind. A
59/// daemon without it drops the field silently, so a consumer that relies on
60/// the provider seeing its role versions checks for this first.
61pub const CAP_ROUTE_ROLE_VERSIONS_V1: &str = "route-role-versions/v1";
62
63/// Module-to-subc op that registers an owner's full scope set.
64pub const SCOPE_SYNC_OP: &str = "scope.sync";
65/// Module-to-subc op that upserts or ends scopes without replacing the owner's
66/// full scope set.
67pub const SCOPE_APPLY_OP: &str = "scope.apply";
68/// Module-to-subc op that reads one scope's current state.
69pub const SCOPE_DESCRIBE_OP: &str = "scope.describe";
70
71/// Most live scopes one owner may hold. A sync naming more is refused whole.
72pub const MAX_LIVE_SCOPES_PER_OWNER: usize = 10_000;
73/// Most bytes one scope's `attributes` may take, measured as compact JSON. A
74/// sync carrying a larger record is refused whole.
75pub const MAX_SCOPE_ATTRIBUTE_BYTES: usize = 4 * 1024;
76/// Most ended scopes the daemon remembers per owner. The oldest is forgotten
77/// first, and reaching the bound never refuses a sync.
78pub const MAX_SCOPE_TOMBSTONES_PER_OWNER: usize = 1_000;
79/// Most modules one targeted carrier entry may list.
80pub const MAX_CARRIER_TARGETS: usize = 16;
81/// Most milliseconds a new scope epoch's deadline may be ahead of the daemon's
82/// current Unix wall clock: 24 hours.
83pub const MAX_SCOPE_EXPIRY_AHEAD_MS: u64 = 86_400_000;
84
85/// What a scope stands for. Closed, and fixed for the life of one
86/// `scope_epoch`: a different kind needs a new epoch, which ends the old scope.
87#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
88#[serde(rename_all = "snake_case")]
89pub enum ScopeKind {
90    Head,
91    Worker,
92    Ephemeral,
93}
94
95/// A link from a scope to another scope, pinned to that scope's session by its
96/// epoch.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(deny_unknown_fields)]
99#[non_exhaustive]
100pub struct ScopeParent {
101    #[serde(deserialize_with = "deserialize_scope_principal")]
102    pub owner: Principal,
103    #[serde(rename = "ref")]
104    pub scope_ref: String,
105    pub scope_epoch: u64,
106}
107
108impl ScopeParent {
109    pub fn new(owner: Principal, scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
110        Self {
111            owner,
112            scope_ref: scope_ref.into(),
113            scope_epoch,
114        }
115    }
116}
117
118/// Who, besides the owner, may open routes under a scope.
119///
120/// `targets` absent means the carrier may open to any module. Present, it
121/// names the only module ids the carrier may open to, and must hold between 1
122/// and [`MAX_CARRIER_TARGETS`] entries: an empty list is refused rather than
123/// read as either "none" or "all".
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(deny_unknown_fields)]
126#[non_exhaustive]
127pub struct ScopeCarrier {
128    #[serde(deserialize_with = "deserialize_scope_principal")]
129    pub principal: Principal,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub targets: Option<Vec<String>>,
132}
133
134impl ScopeCarrier {
135    pub fn new(principal: Principal) -> Self {
136        Self {
137            principal,
138            targets: None,
139        }
140    }
141
142    #[must_use]
143    pub fn with_targets(mut self, targets: Option<Vec<String>>) -> Self {
144        self.targets = targets;
145        self
146    }
147}
148
149/// Declaring this in a manifest's `capabilities.provides` promises that the
150/// module recognises a scope carrying `flow_id` and applies flow behaviour:
151/// it never treats the flow as its owner agent.
152pub const FLOW_SCOPES_CAPABILITY: &str = "flow-scopes/v1";
153
154/// Declaring this in a manifest's `capabilities.provides` promises that the
155/// module recognises `run_id` as one agent run and does not exercise the agent's
156/// delegated authority under that scope.
157pub const AGENT_RUN_SCOPES_CAPABILITY: &str = "agent-run-scopes/v1";
158
159/// The authority attributes the daemon copies into scope stamps unchanged.
160/// Only an owner module named in the daemon config's `scope_authority_owners`
161/// list (by default the module that owns agent sessions) may set them; a scope
162/// owned by any other module must leave them empty.
163#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(deny_unknown_fields)]
165#[non_exhaustive]
166pub struct ScopeAttributes {
167    /// The agent the scope's session belongs to. Identity, never permission.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub agent_id: Option<String>,
170    /// Whether a provider may act as `agent_id`. Refused without `agent_id`.
171    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
172    pub delegates: bool,
173    /// This scope belongs to the named flow, an automated workflow run on an
174    /// agent's behalf. Set only by an authority owner (see above), validated
175    /// with [`validate_flow_id`], and stamped verbatim. It needs neither
176    /// `agent_id` nor `delegates`. Providers treat a module other than the
177    /// owner that opens a route under a flow scope as the flow's carrier.
178    ///
179    /// Like an `agent_id` change, changing this within the same scope epoch is
180    /// accepted: the scope's content version increases (the number providers
181    /// compare to notice a change), and every route under the scope is closed
182    /// with the reason `scope_delegation_changed`, so no live route keeps the
183    /// old identity.
184    /// A target must provide [`FLOW_SCOPES_CAPABILITY`] before the daemon may
185    /// send a bind stamped with this field. Decoding it alone is not enough:
186    /// the target must also apply flow behaviour instead of agent behaviour.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub flow_id: Option<String>,
189    /// Identifies one run carried out on behalf of `agent_id`. Requires
190    /// `agent_id`, forbids `flow_id` and `delegates`, and uses
191    /// [`validate_run_id`]'s token rule.
192    /// Adding, changing or removing it within an epoch increments the scope's
193    /// content version and closes routes under the scope with
194    /// `scope_delegation_changed`. The daemon sends this field in a bind only to
195    /// a target module providing [`AGENT_RUN_SCOPES_CAPABILITY`].
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub run_id: Option<String>,
198}
199
200impl ScopeAttributes {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    #[must_use]
206    pub fn with_agent_id(mut self, agent_id: Option<String>) -> Self {
207        self.agent_id = agent_id;
208        self
209    }
210
211    #[must_use]
212    pub fn with_delegates(mut self, delegates: bool) -> Self {
213        self.delegates = delegates;
214        self
215    }
216
217    #[must_use]
218    pub fn with_flow_id(mut self, flow_id: Option<String>) -> Self {
219        self.flow_id = flow_id;
220        self
221    }
222
223    #[must_use]
224    pub fn with_run_id(mut self, run_id: Option<String>) -> Self {
225        self.run_id = run_id;
226        self
227    }
228
229    pub fn is_empty(&self) -> bool {
230        self.agent_id.is_none()
231            && !self.delegates
232            && self.flow_id.is_none()
233            && self.run_id.is_none()
234    }
235}
236
237/// Check a flow id using the shared opaque-token rule: 1–256 printable,
238/// non-space ASCII bytes. Errors name `flow_id`. Scope refs remain opaque and
239/// are not subject to this token rule.
240pub fn validate_flow_id(flow_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
241    crate::tool_call::validate_opaque_field("flow_id", flow_id)
242}
243
244/// Check a run id using the shared opaque-token rule: 1–256 ASCII bytes in
245/// `0x21`–`0x7E`. Errors name `run_id`.
246pub fn validate_run_id(run_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
247    crate::tool_call::validate_opaque_field("run_id", run_id)
248}
249
250/// One scope as its owner registers it in `scope.sync` or `scope.apply`.
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252#[serde(deny_unknown_fields)]
253#[non_exhaustive]
254pub struct ScopeRecord {
255    #[serde(rename = "ref")]
256    pub scope_ref: String,
257    /// Owner-supplied session number. The owner keeps it with its own record of
258    /// the session, re-sends the same value for the same session after any
259    /// restart, and uses a higher one when it reuses the ref for a new session.
260    pub scope_epoch: u64,
261    pub kind: ScopeKind,
262    /// Absolute Unix wall-clock deadline in milliseconds. It cannot be added,
263    /// changed or removed within this scope epoch; absent means no deadline.
264    /// This is not an authority attribute.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub expires_at_ms: Option<u64>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub parent: Option<ScopeParent>,
269    /// Principals, other than the owner, allowed to register child scopes under
270    /// this one. Listing a principal here grants it nothing else.
271    #[serde(
272        default,
273        skip_serializing_if = "Vec::is_empty",
274        deserialize_with = "deserialize_scope_principals"
275    )]
276    pub child_owners: Vec<Principal>,
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub carriers: Vec<ScopeCarrier>,
279    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
280    pub attributes: ScopeAttributes,
281}
282
283impl ScopeRecord {
284    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, kind: ScopeKind) -> Self {
285        Self {
286            scope_ref: scope_ref.into(),
287            scope_epoch,
288            kind,
289            expires_at_ms: None,
290            parent: None,
291            child_owners: Vec::new(),
292            carriers: Vec::new(),
293            attributes: ScopeAttributes::default(),
294        }
295    }
296
297    #[must_use]
298    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
299        self.expires_at_ms = expires_at_ms;
300        self
301    }
302
303    #[must_use]
304    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
305        self.parent = parent;
306        self
307    }
308
309    #[must_use]
310    pub fn with_child_owners(mut self, child_owners: Vec<Principal>) -> Self {
311        self.child_owners = child_owners;
312        self
313    }
314
315    #[must_use]
316    pub fn with_carriers(mut self, carriers: Vec<ScopeCarrier>) -> Self {
317        self.carriers = carriers;
318        self
319    }
320
321    #[must_use]
322    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
323        self.attributes = attributes;
324        self
325    }
326}
327
328/// A request to end one scope session in `scope.apply`.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(deny_unknown_fields)]
331#[non_exhaustive]
332pub struct ScopeEnd {
333    #[serde(rename = "ref")]
334    pub scope_ref: String,
335    pub scope_epoch: u64,
336}
337
338impl ScopeEnd {
339    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
340        Self {
341            scope_ref: scope_ref.into(),
342            scope_epoch,
343        }
344    }
345}
346
347/// What `scope.apply` did with one requested end.
348#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "snake_case")]
350pub enum ScopeEndOutcome {
351    /// The named scope ref was live at this epoch and was ended.
352    Ended,
353    /// The named scope ref was not live at this epoch; this end request changed
354    /// nothing.
355    NotLive,
356}
357
358/// The per-end result in a `scope.apply` reply, in request order.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
360#[serde(deny_unknown_fields)]
361#[non_exhaustive]
362pub struct ScopeEndResult {
363    #[serde(rename = "ref")]
364    pub scope_ref: String,
365    pub scope_epoch: u64,
366    pub outcome: ScopeEndOutcome,
367}
368
369impl ScopeEndResult {
370    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, outcome: ScopeEndOutcome) -> Self {
371        Self {
372            scope_ref: scope_ref.into(),
373            scope_epoch,
374            outcome,
375        }
376    }
377}
378
379/// The scope a `route.open` asks to be admitted under.
380///
381/// `scope_epoch` is optional on the wire only so that leaving it out is
382/// refused by name (`scope_epoch_required`) rather than as a malformed body:
383/// every opener must name it, the owner included.
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(deny_unknown_fields)]
386pub struct ScopeSelector {
387    #[serde(deserialize_with = "deserialize_scope_principal")]
388    pub owner: Principal,
389    #[serde(rename = "ref")]
390    pub scope_ref: String,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub scope_epoch: Option<u64>,
393}
394
395/// The state of a scope's parent link.
396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
397#[serde(rename_all = "snake_case")]
398pub enum ParentState {
399    /// The parent is live at the named epoch and the link was permitted.
400    Linked,
401    /// The parent's owner has not synced in this daemon incarnation, so the
402    /// link is unverified and grants nothing yet.
403    Pending,
404    /// The parent is gone or live at another epoch, or the link was refused
405    /// when the parent's owner synced. Final for this link.
406    Ended,
407}
408
409/// What a `scope.sync` or `scope.apply` did with one record.
410#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
411#[serde(rename_all = "snake_case")]
412pub enum ScopeRecordOutcome {
413    /// The ref was not live before; the scope was created.
414    Created,
415    /// The ref was live at a lower epoch; that scope ended and this one began.
416    Replaced,
417    /// The ref was live at this epoch and its content changed.
418    Updated,
419    /// The ref was live at this epoch with identical content; its `version`
420    /// did not move.
421    Unchanged,
422    /// The record was refused on its own merits (`code` says why). The refusal
423    /// itself does not change the ref or undo any expiry processing.
424    Refused,
425}
426
427/// The per-record result in a `scope.sync` or `scope.apply` reply, in request order.
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
429pub struct ScopeRecordResult {
430    #[serde(rename = "ref")]
431    pub scope_ref: String,
432    /// The epoch the record named, which for a refusal may differ from the
433    /// epoch still held.
434    pub scope_epoch: u64,
435    pub outcome: ScopeRecordOutcome,
436    /// The refusal code when `outcome` is `refused`.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub code: Option<String>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub message: Option<String>,
441    /// The daemon's content counter for the scope held under this ref after
442    /// the sync; absent when no scope is live under it.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub version: Option<u64>,
445    /// The parent link's state after the sync, for a scope with a parent.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub parent_state: Option<ParentState>,
448}
449
450/// A scope session of the calling owner that ended through omission from
451/// `scope.sync`, an explicit `scope.apply` end, replacement by a higher epoch
452/// for the same ref or expiry.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub struct ScopeEnded {
455    #[serde(rename = "ref")]
456    pub scope_ref: String,
457    pub scope_epoch: u64,
458}
459
460/// `scope.describe`'s answer about one `(owner, ref)`.
461#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
462#[serde(rename_all = "snake_case")]
463pub enum ScopeStatus {
464    Live,
465    /// Ended in this daemon incarnation, and still remembered.
466    Ended,
467    /// Neither live nor remembered as ended. With `owner_synced` true the scope
468    /// is gone: an owner's first sync of an incarnation is its full set. With
469    /// `owner_synced` false and `owner_configured` true the owner has not
470    /// re-synced since a daemon restart, so a reader waits. With
471    /// `owner_configured` false the owner will never sync.
472    NotLive,
473}
474
475/// The fields the daemon stamps for a live scope.
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
477pub struct ScopeStamp {
478    pub owner: Principal,
479    #[serde(rename = "ref")]
480    pub scope_ref: String,
481    pub scope_epoch: u64,
482    pub kind: ScopeKind,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub parent: Option<ScopeParent>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub parent_state: Option<ParentState>,
487    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
488    pub attributes: ScopeAttributes,
489    /// Whether the owner is listed in the daemon's `scope_authority_owners`.
490    /// Providers decide on this flag and keep no copy of the list.
491    pub owner_authorized: bool,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::tool_call::OpaqueFieldError;
498
499    #[test]
500    fn flow_id_uses_the_shared_opaque_token_bounds_and_names_its_field() {
501        let field = "flow_id";
502        assert_eq!(validate_flow_id(""), Err(OpaqueFieldError::Empty { field }));
503        assert_eq!(validate_flow_id("f"), Ok(()));
504        assert_eq!(validate_flow_id(&"f".repeat(256)), Ok(()));
505        assert_eq!(
506            validate_flow_id(&"f".repeat(257)),
507            Err(OpaqueFieldError::TooLong { field, length: 257 })
508        );
509        assert_eq!(validate_flow_id("!~Flow:7/step"), Ok(()));
510        for bad in ["f é", "f\t", "fé", "f\u{7f}"] {
511            let error = validate_flow_id(bad).unwrap_err();
512            assert_eq!(
513                error,
514                OpaqueFieldError::InvalidCharacter { field, index: 1 }
515            );
516            assert_eq!(error.field(), "flow_id");
517        }
518    }
519
520    #[test]
521    fn flow_only_attributes_round_trip_and_absence_keeps_the_bytes() {
522        let attributes = ScopeAttributes::default();
523        assert!(attributes.is_empty());
524        assert_eq!(serde_json::to_string(&attributes).unwrap(), "{}");
525        assert_eq!(
526            serde_json::from_str::<ScopeAttributes>("{}").unwrap(),
527            attributes
528        );
529        let attributes = ScopeAttributes {
530            flow_id: Some("flow:7".to_string()),
531            ..ScopeAttributes::default()
532        };
533        assert!(!attributes.is_empty());
534        let encoded = serde_json::to_string(&attributes).unwrap();
535        assert_eq!(encoded, r#"{"flow_id":"flow:7"}"#);
536        assert_eq!(
537            serde_json::from_str::<ScopeAttributes>(&encoded).unwrap(),
538            attributes
539        );
540        assert!(
541            serde_json::from_str::<ScopeAttributes>(r#"{"flow_id":"flow:7","unknown":true}"#)
542                .is_err()
543        );
544    }
545}