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/// A scope bind-proof hash is the lowercase hexadecimal SHA-256 digest.
53pub fn validate_bind_proof_sha256(hash: &str) -> Result<(), &'static str> {
54    if hash.len() == 64
55        && hash
56            .bytes()
57            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
58    {
59        Ok(())
60    } else {
61        Err("bind_proof_sha256 must be exactly 64 lowercase hexadecimal characters")
62    }
63}
64
65fn deserialize_bind_proof_sha256<'de, D: serde::Deserializer<'de>>(
66    deserializer: D,
67) -> Result<Option<String>, D::Error> {
68    let hash = Option::<String>::deserialize(deserializer)?;
69    if let Some(hash) = &hash {
70        validate_bind_proof_sha256(hash).map_err(serde::de::Error::custom)?;
71    }
72    Ok(hash)
73}
74
75/// The `server.describe` capability a daemon advertises when it admits routes
76/// under scopes. A carrier that needs a scoped route and does not see it fails
77/// the call (`scope_unsupported`) instead of opening an unscoped route.
78pub const CAP_SCOPES_V1: &str = "scopes/v1";
79
80/// The `server.describe` capability a daemon advertises when it checks a
81/// `route.open`'s `role_versions` and forwards them on the module's bind. A
82/// daemon without it drops the field silently, so a consumer that relies on
83/// the provider seeing its role versions checks for this first.
84pub const CAP_ROUTE_ROLE_VERSIONS_V1: &str = "route-role-versions/v1";
85
86/// Module-to-subc op that registers an owner's full scope set.
87pub const SCOPE_SYNC_OP: &str = "scope.sync";
88/// Module-to-subc op that upserts or ends scopes without replacing the owner's
89/// full scope set.
90pub const SCOPE_APPLY_OP: &str = "scope.apply";
91/// Module-to-subc op that reads one scope's current state.
92pub const SCOPE_DESCRIBE_OP: &str = "scope.describe";
93
94/// Most live scopes one owner may hold. A sync naming more is refused whole.
95pub const MAX_LIVE_SCOPES_PER_OWNER: usize = 10_000;
96/// Most bytes one scope's `attributes` may take, measured as compact JSON. A
97/// sync carrying a larger record is refused whole.
98pub const MAX_SCOPE_ATTRIBUTE_BYTES: usize = 4 * 1024;
99/// Most ended scopes the daemon remembers per owner. The oldest is forgotten
100/// first, and reaching the bound never refuses a sync.
101pub const MAX_SCOPE_TOMBSTONES_PER_OWNER: usize = 1_000;
102/// Most modules one targeted carrier entry may list.
103pub const MAX_CARRIER_TARGETS: usize = 16;
104/// Most milliseconds a new scope epoch's deadline may be ahead of the daemon's
105/// current Unix wall clock: 24 hours.
106pub const MAX_SCOPE_EXPIRY_AHEAD_MS: u64 = 86_400_000;
107
108/// What a scope stands for. Closed, and fixed for the life of one
109/// `scope_epoch`: a different kind needs a new epoch, which ends the old scope.
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
111#[serde(rename_all = "snake_case")]
112pub enum ScopeKind {
113    Head,
114    Worker,
115    Ephemeral,
116}
117
118/// A link from a scope to another scope, pinned to that scope's session by its
119/// epoch.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(deny_unknown_fields)]
122#[non_exhaustive]
123pub struct ScopeParent {
124    #[serde(deserialize_with = "deserialize_scope_principal")]
125    pub owner: Principal,
126    #[serde(rename = "ref")]
127    pub scope_ref: String,
128    pub scope_epoch: u64,
129}
130
131impl ScopeParent {
132    pub fn new(owner: Principal, scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
133        Self {
134            owner,
135            scope_ref: scope_ref.into(),
136            scope_epoch,
137        }
138    }
139}
140
141/// Who, besides the owner, may open routes under a scope.
142///
143/// `targets` absent means the carrier may open to any module. Present, it
144/// names the only module ids the carrier may open to, and must hold between 1
145/// and [`MAX_CARRIER_TARGETS`] entries: an empty list is refused rather than
146/// read as either "none" or "all".
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(deny_unknown_fields)]
149#[non_exhaustive]
150pub struct ScopeCarrier {
151    #[serde(deserialize_with = "deserialize_scope_principal")]
152    pub principal: Principal,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub targets: Option<Vec<String>>,
155}
156
157impl ScopeCarrier {
158    pub fn new(principal: Principal) -> Self {
159        Self {
160            principal,
161            targets: None,
162        }
163    }
164
165    #[must_use]
166    pub fn with_targets(mut self, targets: Option<Vec<String>>) -> Self {
167        self.targets = targets;
168        self
169    }
170}
171
172/// Declaring this in a manifest's `capabilities.provides` promises that the
173/// module recognises a scope carrying `flow_id` and applies flow behaviour:
174/// it never treats the flow as its owner agent.
175/// During migration, either this capability or
176/// [`crate::call_mode::CALL_MODES_CAPABILITY`] admits flow scopes.
177pub const FLOW_SCOPES_CAPABILITY: &str = "flow-scopes/v1";
178
179/// Declaring this in a manifest's `capabilities.provides` promises that the
180/// module recognises `run_id` as one agent run and does not exercise the agent's
181/// delegated authority under that scope.
182/// During migration it admits non-delegating run scopes; delegating runs require
183/// [`crate::call_mode::CALL_MODES_CAPABILITY`] instead.
184pub const AGENT_RUN_SCOPES_CAPABILITY: &str = "agent-run-scopes/v1";
185
186/// The authority attributes the daemon copies into scope stamps unchanged.
187/// Only an owner module named in the daemon config's `scope_authority_owners`
188/// list (by default the module that owns agent sessions) may set them; a scope
189/// owned by any other module must leave them empty.
190#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(deny_unknown_fields)]
192#[non_exhaustive]
193pub struct ScopeAttributes {
194    /// The agent the scope's session belongs to. Identity, never permission.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub agent_id: Option<String>,
197    /// Whether a provider may act as `agent_id`. Refused without `agent_id`.
198    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
199    pub delegates: bool,
200    /// This scope belongs to the named flow, an automated workflow run on an
201    /// agent's behalf. Set only by an authority owner (see above), validated
202    /// with [`validate_flow_id`], and stamped verbatim. It needs neither
203    /// `agent_id` nor `delegates`. Providers treat a module other than the
204    /// owner that opens a route under a flow scope as the flow's carrier.
205    ///
206    /// Like an `agent_id` change, changing this within the same scope epoch is
207    /// accepted: the scope's content version increases (the number providers
208    /// compare to notice a change), and every route under the scope is closed
209    /// with the reason `scope_delegation_changed`, so no live route keeps the
210    /// old identity.
211    /// A target must provide [`FLOW_SCOPES_CAPABILITY`] or
212    /// [`crate::call_mode::CALL_MODES_CAPABILITY`] before the daemon may send a
213    /// bind stamped with this field. Decoding it alone is not enough:
214    /// the target must also apply flow behaviour instead of agent behaviour.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub flow_id: Option<String>,
217    /// Identifies one run carried out on behalf of `agent_id`. Requires
218    /// `agent_id`, forbids `flow_id`, and uses
219    /// [`validate_run_id`]'s token rule.
220    /// Adding, changing or removing it within an epoch increments the scope's
221    /// content version and closes routes under the scope with
222    /// `scope_delegation_changed`. The daemon sends this field in a bind only to
223    /// a target module providing [`AGENT_RUN_SCOPES_CAPABILITY`] or
224    /// [`crate::call_mode::CALL_MODES_CAPABILITY`]. A delegating run scope is
225    /// accepted as a record, but binds only to a target providing the latter.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub run_id: Option<String>,
228}
229
230impl ScopeAttributes {
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    #[must_use]
236    pub fn with_agent_id(mut self, agent_id: Option<String>) -> Self {
237        self.agent_id = agent_id;
238        self
239    }
240
241    #[must_use]
242    pub fn with_delegates(mut self, delegates: bool) -> Self {
243        self.delegates = delegates;
244        self
245    }
246
247    #[must_use]
248    pub fn with_flow_id(mut self, flow_id: Option<String>) -> Self {
249        self.flow_id = flow_id;
250        self
251    }
252
253    #[must_use]
254    pub fn with_run_id(mut self, run_id: Option<String>) -> Self {
255        self.run_id = run_id;
256        self
257    }
258
259    pub fn is_empty(&self) -> bool {
260        self.agent_id.is_none()
261            && !self.delegates
262            && self.flow_id.is_none()
263            && self.run_id.is_none()
264    }
265}
266
267/// Check a flow id using the shared opaque-token rule: 1–256 printable,
268/// non-space ASCII bytes. Errors name `flow_id`. Scope refs remain opaque and
269/// are not subject to this token rule.
270pub fn validate_flow_id(flow_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
271    crate::tool_call::validate_opaque_field("flow_id", flow_id)
272}
273
274/// Check a run id using the shared opaque-token rule: 1–256 ASCII bytes in
275/// `0x21`–`0x7E`. Errors name `run_id`.
276pub fn validate_run_id(run_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
277    crate::tool_call::validate_opaque_field("run_id", run_id)
278}
279
280/// One scope as its owner registers it in `scope.sync` or `scope.apply`.
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
282#[serde(deny_unknown_fields)]
283#[non_exhaustive]
284pub struct ScopeRecord {
285    #[serde(rename = "ref")]
286    pub scope_ref: String,
287    /// Owner-supplied session number. The owner keeps it with its own record of
288    /// the session, re-sends the same value for the same session after any
289    /// restart, and uses a higher one when it reuses the ref for a new session.
290    pub scope_epoch: u64,
291    pub kind: ScopeKind,
292    /// Only the holder of the scope's bind proof can bind it when this hash is
293    /// present. Without a hash, admission is unchanged and proofs are ignored.
294    #[serde(
295        default,
296        skip_serializing_if = "Option::is_none",
297        deserialize_with = "deserialize_bind_proof_sha256"
298    )]
299    pub bind_proof_sha256: Option<String>,
300    /// Refuse an epoch replacement or bind-proof change while routes are bound
301    /// to the live epoch, including binds admitted but not yet committed, or
302    /// connected presence holders remain. Send only after capability discovery.
303    /// This is a request condition, not a stored scope attribute.
304    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
305    pub replace_only_if_idle: bool,
306    /// Absolute Unix wall-clock deadline in milliseconds. It cannot be added,
307    /// changed or removed within this scope epoch; absent means no deadline.
308    /// This is not an authority attribute.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub expires_at_ms: Option<u64>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub parent: Option<ScopeParent>,
313    /// Principals, other than the owner, allowed to register child scopes under
314    /// this one. Listing a principal here grants it nothing else.
315    #[serde(
316        default,
317        skip_serializing_if = "Vec::is_empty",
318        deserialize_with = "deserialize_scope_principals"
319    )]
320    pub child_owners: Vec<Principal>,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub carriers: Vec<ScopeCarrier>,
323    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
324    pub attributes: ScopeAttributes,
325}
326
327impl ScopeRecord {
328    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, kind: ScopeKind) -> Self {
329        Self {
330            scope_ref: scope_ref.into(),
331            scope_epoch,
332            kind,
333            bind_proof_sha256: None,
334            replace_only_if_idle: false,
335            expires_at_ms: None,
336            parent: None,
337            child_owners: Vec::new(),
338            carriers: Vec::new(),
339            attributes: ScopeAttributes::default(),
340        }
341    }
342
343    #[must_use]
344    pub fn with_bind_proof_sha256(mut self, hash: Option<String>) -> Self {
345        self.bind_proof_sha256 = hash;
346        self
347    }
348
349    #[must_use]
350    pub fn with_replace_only_if_idle(mut self, replace_only_if_idle: bool) -> Self {
351        self.replace_only_if_idle = replace_only_if_idle;
352        self
353    }
354
355    #[must_use]
356    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
357        self.expires_at_ms = expires_at_ms;
358        self
359    }
360
361    #[must_use]
362    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
363        self.parent = parent;
364        self
365    }
366
367    #[must_use]
368    pub fn with_child_owners(mut self, child_owners: Vec<Principal>) -> Self {
369        self.child_owners = child_owners;
370        self
371    }
372
373    #[must_use]
374    pub fn with_carriers(mut self, carriers: Vec<ScopeCarrier>) -> Self {
375        self.carriers = carriers;
376        self
377    }
378
379    #[must_use]
380    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
381        self.attributes = attributes;
382        self
383    }
384}
385
386/// A request to end one scope session in `scope.apply`.
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
388#[serde(deny_unknown_fields)]
389#[non_exhaustive]
390pub struct ScopeEnd {
391    #[serde(rename = "ref")]
392    pub scope_ref: String,
393    pub scope_epoch: u64,
394}
395
396impl ScopeEnd {
397    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
398        Self {
399            scope_ref: scope_ref.into(),
400            scope_epoch,
401        }
402    }
403}
404
405/// What `scope.apply` did with one requested end.
406#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
407#[serde(rename_all = "snake_case")]
408pub enum ScopeEndOutcome {
409    /// The named scope ref was live at this epoch and was ended.
410    Ended,
411    /// The named scope ref was not live at this epoch; this end request changed
412    /// nothing.
413    NotLive,
414}
415
416/// The per-end result in a `scope.apply` reply, in request order.
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
418#[serde(deny_unknown_fields)]
419#[non_exhaustive]
420pub struct ScopeEndResult {
421    #[serde(rename = "ref")]
422    pub scope_ref: String,
423    pub scope_epoch: u64,
424    pub outcome: ScopeEndOutcome,
425}
426
427impl ScopeEndResult {
428    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, outcome: ScopeEndOutcome) -> Self {
429        Self {
430            scope_ref: scope_ref.into(),
431            scope_epoch,
432            outcome,
433        }
434    }
435}
436
437/// The scope a `route.open` asks to be admitted under.
438///
439/// `scope_epoch` is optional on the wire only so that leaving it out is
440/// refused by name (`scope_epoch_required`) rather than as a malformed body:
441/// every opener must name it, the owner included.
442#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
443#[serde(deny_unknown_fields)]
444#[non_exhaustive]
445pub struct ScopeSelector {
446    #[serde(deserialize_with = "deserialize_scope_principal")]
447    pub owner: Principal,
448    #[serde(rename = "ref")]
449    pub scope_ref: String,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub scope_epoch: Option<u64>,
452    /// Base64url-encoded 32-byte secret, without padding. Ignored for records
453    /// without a bind-proof hash, so holders may always send their proof.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub bind_proof: Option<String>,
456    /// Keep connection-held presence after an admitted open, even if its route
457    /// closes. Send only to daemons advertising `scope-replace-if-idle/v1`.
458    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
459    pub hold_presence: bool,
460}
461
462impl ScopeSelector {
463    pub fn new(owner: Principal, scope_ref: impl Into<String>, scope_epoch: Option<u64>) -> Self {
464        Self {
465            owner,
466            scope_ref: scope_ref.into(),
467            scope_epoch,
468            bind_proof: None,
469            hold_presence: false,
470        }
471    }
472
473    #[must_use]
474    pub fn with_bind_proof(mut self, proof: Option<String>) -> Self {
475        self.bind_proof = proof;
476        self
477    }
478
479    #[must_use]
480    pub fn with_hold_presence(mut self, hold_presence: bool) -> Self {
481        self.hold_presence = hold_presence;
482        self
483    }
484}
485
486impl std::fmt::Debug for ScopeSelector {
487    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488        f.debug_struct("ScopeSelector")
489            .field("owner", &self.owner)
490            .field("scope_ref", &self.scope_ref)
491            .field("scope_epoch", &self.scope_epoch)
492            .field(
493                "bind_proof",
494                &self.bind_proof.as_ref().map(|_| "<redacted>"),
495            )
496            .finish()
497    }
498}
499
500impl std::fmt::Display for ScopeSelector {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        std::fmt::Debug::fmt(self, f)
503    }
504}
505
506/// The state of a scope's parent link.
507#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
508#[serde(rename_all = "snake_case")]
509pub enum ParentState {
510    /// The parent is live at the named epoch and the link was permitted.
511    Linked,
512    /// The parent's owner has not synced in this daemon incarnation, so the
513    /// link is unverified and grants nothing yet.
514    Pending,
515    /// The parent is gone or live at another epoch, or the link was refused
516    /// when the parent's owner synced. Final for this link.
517    Ended,
518}
519
520/// What a `scope.sync` or `scope.apply` did with one record.
521#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
522#[serde(rename_all = "snake_case")]
523pub enum ScopeRecordOutcome {
524    /// The ref was not live before; the scope was created.
525    Created,
526    /// The ref was live at a lower epoch; that scope ended and this one began.
527    Replaced,
528    /// The ref was live at this epoch and its content changed.
529    Updated,
530    /// The ref was live at this epoch with identical content; its `version`
531    /// did not move.
532    Unchanged,
533    /// The record was refused on its own merits (`code` says why). The refusal
534    /// itself does not change the ref or undo any expiry processing.
535    Refused,
536}
537
538/// The per-record result in a `scope.sync` or `scope.apply` reply, in request order.
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
540pub struct ScopeRecordResult {
541    #[serde(rename = "ref")]
542    pub scope_ref: String,
543    /// The epoch the record named, which for a refusal may differ from the
544    /// epoch still held.
545    pub scope_epoch: u64,
546    pub outcome: ScopeRecordOutcome,
547    /// The refusal code when `outcome` is `refused`.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub code: Option<String>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub message: Option<String>,
552    /// The daemon's content counter for the scope held under this ref after
553    /// the sync; absent when no scope is live under it.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub version: Option<u64>,
556    /// The parent link's state after the sync, for a scope with a parent.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub parent_state: Option<ParentState>,
559}
560
561/// A scope session of the calling owner that ended through omission from
562/// `scope.sync`, an explicit `scope.apply` end, replacement by a higher epoch
563/// for the same ref or expiry.
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
565pub struct ScopeEnded {
566    #[serde(rename = "ref")]
567    pub scope_ref: String,
568    pub scope_epoch: u64,
569}
570
571/// `scope.describe`'s answer about one `(owner, ref)`.
572#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
573#[serde(rename_all = "snake_case")]
574pub enum ScopeStatus {
575    Live,
576    /// Ended in this daemon incarnation, and still remembered.
577    Ended,
578    /// Neither live nor remembered as ended. With `owner_synced` true the scope
579    /// is gone: an owner's first sync of an incarnation is its full set. With
580    /// `owner_synced` false and `owner_configured` true the owner has not
581    /// re-synced since a daemon restart, so a reader waits. With
582    /// `owner_configured` false the owner will never sync.
583    NotLive,
584}
585
586/// The fields the daemon stamps for a live scope.
587/// Build with [`ScopeStamp::new`] and the setters so future fields are additive.
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
589#[non_exhaustive]
590pub struct ScopeStamp {
591    pub owner: Principal,
592    #[serde(rename = "ref")]
593    pub scope_ref: String,
594    pub scope_epoch: u64,
595    pub kind: ScopeKind,
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub parent: Option<ScopeParent>,
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub parent_state: Option<ParentState>,
600    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
601    pub attributes: ScopeAttributes,
602    /// Whether the owner is listed in the daemon's `scope_authority_owners`.
603    /// Providers decide on this flag and keep no copy of the list.
604    pub owner_authorized: bool,
605    /// Absolute Unix wall-clock deadline in milliseconds, fixed for this epoch.
606    /// On route binds the daemon sends this deadline only to targets declaring
607    /// [`crate::call_mode::CALL_MODES_CAPABILITY`], because older providers may
608    /// reject unfamiliar scope-stamp fields.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub expires_at_ms: Option<u64>,
611}
612
613impl ScopeStamp {
614    /// A stamp with no parent, attributes or deadline, and no owner authority.
615    pub fn new(
616        owner: Principal,
617        scope_ref: impl Into<String>,
618        scope_epoch: u64,
619        kind: ScopeKind,
620    ) -> Self {
621        Self {
622            owner,
623            scope_ref: scope_ref.into(),
624            scope_epoch,
625            kind,
626            parent: None,
627            parent_state: None,
628            attributes: ScopeAttributes::new(),
629            owner_authorized: false,
630            expires_at_ms: None,
631        }
632    }
633
634    #[must_use]
635    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
636        self.parent = parent;
637        self
638    }
639
640    #[must_use]
641    pub fn with_parent_state(mut self, parent_state: Option<ParentState>) -> Self {
642        self.parent_state = parent_state;
643        self
644    }
645
646    #[must_use]
647    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
648        self.attributes = attributes;
649        self
650    }
651
652    #[must_use]
653    pub fn with_owner_authorized(mut self, owner_authorized: bool) -> Self {
654        self.owner_authorized = owner_authorized;
655        self
656    }
657
658    #[must_use]
659    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
660        self.expires_at_ms = expires_at_ms;
661        self
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::tool_call::OpaqueFieldError;
669
670    #[test]
671    fn flow_id_uses_the_shared_opaque_token_bounds_and_names_its_field() {
672        let field = "flow_id";
673        assert_eq!(validate_flow_id(""), Err(OpaqueFieldError::Empty { field }));
674        assert_eq!(validate_flow_id("f"), Ok(()));
675        assert_eq!(validate_flow_id(&"f".repeat(256)), Ok(()));
676        assert_eq!(
677            validate_flow_id(&"f".repeat(257)),
678            Err(OpaqueFieldError::TooLong { field, length: 257 })
679        );
680        assert_eq!(validate_flow_id("!~Flow:7/step"), Ok(()));
681        for bad in ["f é", "f\t", "fé", "f\u{7f}"] {
682            let error = validate_flow_id(bad).unwrap_err();
683            assert_eq!(
684                error,
685                OpaqueFieldError::InvalidCharacter { field, index: 1 }
686            );
687            assert_eq!(error.field(), "flow_id");
688        }
689    }
690
691    #[test]
692    fn flow_only_attributes_round_trip_and_absence_keeps_the_bytes() {
693        let attributes = ScopeAttributes::default();
694        assert!(attributes.is_empty());
695        assert_eq!(serde_json::to_string(&attributes).unwrap(), "{}");
696        assert_eq!(
697            serde_json::from_str::<ScopeAttributes>("{}").unwrap(),
698            attributes
699        );
700        let attributes = ScopeAttributes {
701            flow_id: Some("flow:7".to_string()),
702            ..ScopeAttributes::default()
703        };
704        assert!(!attributes.is_empty());
705        let encoded = serde_json::to_string(&attributes).unwrap();
706        assert_eq!(encoded, r#"{"flow_id":"flow:7"}"#);
707        assert_eq!(
708            serde_json::from_str::<ScopeAttributes>(&encoded).unwrap(),
709            attributes
710        );
711        assert!(
712            serde_json::from_str::<ScopeAttributes>(r#"{"flow_id":"flow:7","unknown":true}"#)
713                .is_err()
714        );
715    }
716}