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.
152/// During migration, either this capability or
153/// [`crate::call_mode::CALL_MODES_CAPABILITY`] admits flow scopes.
154pub const FLOW_SCOPES_CAPABILITY: &str = "flow-scopes/v1";
155
156/// Declaring this in a manifest's `capabilities.provides` promises that the
157/// module recognises `run_id` as one agent run and does not exercise the agent's
158/// delegated authority under that scope.
159/// During migration it admits non-delegating run scopes; delegating runs require
160/// [`crate::call_mode::CALL_MODES_CAPABILITY`] instead.
161pub const AGENT_RUN_SCOPES_CAPABILITY: &str = "agent-run-scopes/v1";
162
163/// The authority attributes the daemon copies into scope stamps unchanged.
164/// Only an owner module named in the daemon config's `scope_authority_owners`
165/// list (by default the module that owns agent sessions) may set them; a scope
166/// owned by any other module must leave them empty.
167#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(deny_unknown_fields)]
169#[non_exhaustive]
170pub struct ScopeAttributes {
171    /// The agent the scope's session belongs to. Identity, never permission.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub agent_id: Option<String>,
174    /// Whether a provider may act as `agent_id`. Refused without `agent_id`.
175    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176    pub delegates: bool,
177    /// This scope belongs to the named flow, an automated workflow run on an
178    /// agent's behalf. Set only by an authority owner (see above), validated
179    /// with [`validate_flow_id`], and stamped verbatim. It needs neither
180    /// `agent_id` nor `delegates`. Providers treat a module other than the
181    /// owner that opens a route under a flow scope as the flow's carrier.
182    ///
183    /// Like an `agent_id` change, changing this within the same scope epoch is
184    /// accepted: the scope's content version increases (the number providers
185    /// compare to notice a change), and every route under the scope is closed
186    /// with the reason `scope_delegation_changed`, so no live route keeps the
187    /// old identity.
188    /// A target must provide [`FLOW_SCOPES_CAPABILITY`] or
189    /// [`crate::call_mode::CALL_MODES_CAPABILITY`] before the daemon may send a
190    /// bind stamped with this field. Decoding it alone is not enough:
191    /// the target must also apply flow behaviour instead of agent behaviour.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub flow_id: Option<String>,
194    /// Identifies one run carried out on behalf of `agent_id`. Requires
195    /// `agent_id`, forbids `flow_id`, and uses
196    /// [`validate_run_id`]'s token rule.
197    /// Adding, changing or removing it within an epoch increments the scope's
198    /// content version and closes routes under the scope with
199    /// `scope_delegation_changed`. The daemon sends this field in a bind only to
200    /// a target module providing [`AGENT_RUN_SCOPES_CAPABILITY`] or
201    /// [`crate::call_mode::CALL_MODES_CAPABILITY`]. A delegating run scope is
202    /// accepted as a record, but binds only to a target providing the latter.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub run_id: Option<String>,
205}
206
207impl ScopeAttributes {
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    #[must_use]
213    pub fn with_agent_id(mut self, agent_id: Option<String>) -> Self {
214        self.agent_id = agent_id;
215        self
216    }
217
218    #[must_use]
219    pub fn with_delegates(mut self, delegates: bool) -> Self {
220        self.delegates = delegates;
221        self
222    }
223
224    #[must_use]
225    pub fn with_flow_id(mut self, flow_id: Option<String>) -> Self {
226        self.flow_id = flow_id;
227        self
228    }
229
230    #[must_use]
231    pub fn with_run_id(mut self, run_id: Option<String>) -> Self {
232        self.run_id = run_id;
233        self
234    }
235
236    pub fn is_empty(&self) -> bool {
237        self.agent_id.is_none()
238            && !self.delegates
239            && self.flow_id.is_none()
240            && self.run_id.is_none()
241    }
242}
243
244/// Check a flow id using the shared opaque-token rule: 1–256 printable,
245/// non-space ASCII bytes. Errors name `flow_id`. Scope refs remain opaque and
246/// are not subject to this token rule.
247pub fn validate_flow_id(flow_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
248    crate::tool_call::validate_opaque_field("flow_id", flow_id)
249}
250
251/// Check a run id using the shared opaque-token rule: 1–256 ASCII bytes in
252/// `0x21`–`0x7E`. Errors name `run_id`.
253pub fn validate_run_id(run_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
254    crate::tool_call::validate_opaque_field("run_id", run_id)
255}
256
257/// One scope as its owner registers it in `scope.sync` or `scope.apply`.
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(deny_unknown_fields)]
260#[non_exhaustive]
261pub struct ScopeRecord {
262    #[serde(rename = "ref")]
263    pub scope_ref: String,
264    /// Owner-supplied session number. The owner keeps it with its own record of
265    /// the session, re-sends the same value for the same session after any
266    /// restart, and uses a higher one when it reuses the ref for a new session.
267    pub scope_epoch: u64,
268    pub kind: ScopeKind,
269    /// Absolute Unix wall-clock deadline in milliseconds. It cannot be added,
270    /// changed or removed within this scope epoch; absent means no deadline.
271    /// This is not an authority attribute.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub expires_at_ms: Option<u64>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub parent: Option<ScopeParent>,
276    /// Principals, other than the owner, allowed to register child scopes under
277    /// this one. Listing a principal here grants it nothing else.
278    #[serde(
279        default,
280        skip_serializing_if = "Vec::is_empty",
281        deserialize_with = "deserialize_scope_principals"
282    )]
283    pub child_owners: Vec<Principal>,
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub carriers: Vec<ScopeCarrier>,
286    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
287    pub attributes: ScopeAttributes,
288}
289
290impl ScopeRecord {
291    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, kind: ScopeKind) -> Self {
292        Self {
293            scope_ref: scope_ref.into(),
294            scope_epoch,
295            kind,
296            expires_at_ms: None,
297            parent: None,
298            child_owners: Vec::new(),
299            carriers: Vec::new(),
300            attributes: ScopeAttributes::default(),
301        }
302    }
303
304    #[must_use]
305    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
306        self.expires_at_ms = expires_at_ms;
307        self
308    }
309
310    #[must_use]
311    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
312        self.parent = parent;
313        self
314    }
315
316    #[must_use]
317    pub fn with_child_owners(mut self, child_owners: Vec<Principal>) -> Self {
318        self.child_owners = child_owners;
319        self
320    }
321
322    #[must_use]
323    pub fn with_carriers(mut self, carriers: Vec<ScopeCarrier>) -> Self {
324        self.carriers = carriers;
325        self
326    }
327
328    #[must_use]
329    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
330        self.attributes = attributes;
331        self
332    }
333}
334
335/// A request to end one scope session in `scope.apply`.
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(deny_unknown_fields)]
338#[non_exhaustive]
339pub struct ScopeEnd {
340    #[serde(rename = "ref")]
341    pub scope_ref: String,
342    pub scope_epoch: u64,
343}
344
345impl ScopeEnd {
346    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
347        Self {
348            scope_ref: scope_ref.into(),
349            scope_epoch,
350        }
351    }
352}
353
354/// What `scope.apply` did with one requested end.
355#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
356#[serde(rename_all = "snake_case")]
357pub enum ScopeEndOutcome {
358    /// The named scope ref was live at this epoch and was ended.
359    Ended,
360    /// The named scope ref was not live at this epoch; this end request changed
361    /// nothing.
362    NotLive,
363}
364
365/// The per-end result in a `scope.apply` reply, in request order.
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(deny_unknown_fields)]
368#[non_exhaustive]
369pub struct ScopeEndResult {
370    #[serde(rename = "ref")]
371    pub scope_ref: String,
372    pub scope_epoch: u64,
373    pub outcome: ScopeEndOutcome,
374}
375
376impl ScopeEndResult {
377    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, outcome: ScopeEndOutcome) -> Self {
378        Self {
379            scope_ref: scope_ref.into(),
380            scope_epoch,
381            outcome,
382        }
383    }
384}
385
386/// The scope a `route.open` asks to be admitted under.
387///
388/// `scope_epoch` is optional on the wire only so that leaving it out is
389/// refused by name (`scope_epoch_required`) rather than as a malformed body:
390/// every opener must name it, the owner included.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[serde(deny_unknown_fields)]
393pub struct ScopeSelector {
394    #[serde(deserialize_with = "deserialize_scope_principal")]
395    pub owner: Principal,
396    #[serde(rename = "ref")]
397    pub scope_ref: String,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub scope_epoch: Option<u64>,
400}
401
402/// The state of a scope's parent link.
403#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
404#[serde(rename_all = "snake_case")]
405pub enum ParentState {
406    /// The parent is live at the named epoch and the link was permitted.
407    Linked,
408    /// The parent's owner has not synced in this daemon incarnation, so the
409    /// link is unverified and grants nothing yet.
410    Pending,
411    /// The parent is gone or live at another epoch, or the link was refused
412    /// when the parent's owner synced. Final for this link.
413    Ended,
414}
415
416/// What a `scope.sync` or `scope.apply` did with one record.
417#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
418#[serde(rename_all = "snake_case")]
419pub enum ScopeRecordOutcome {
420    /// The ref was not live before; the scope was created.
421    Created,
422    /// The ref was live at a lower epoch; that scope ended and this one began.
423    Replaced,
424    /// The ref was live at this epoch and its content changed.
425    Updated,
426    /// The ref was live at this epoch with identical content; its `version`
427    /// did not move.
428    Unchanged,
429    /// The record was refused on its own merits (`code` says why). The refusal
430    /// itself does not change the ref or undo any expiry processing.
431    Refused,
432}
433
434/// The per-record result in a `scope.sync` or `scope.apply` reply, in request order.
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436pub struct ScopeRecordResult {
437    #[serde(rename = "ref")]
438    pub scope_ref: String,
439    /// The epoch the record named, which for a refusal may differ from the
440    /// epoch still held.
441    pub scope_epoch: u64,
442    pub outcome: ScopeRecordOutcome,
443    /// The refusal code when `outcome` is `refused`.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub code: Option<String>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub message: Option<String>,
448    /// The daemon's content counter for the scope held under this ref after
449    /// the sync; absent when no scope is live under it.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub version: Option<u64>,
452    /// The parent link's state after the sync, for a scope with a parent.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub parent_state: Option<ParentState>,
455}
456
457/// A scope session of the calling owner that ended through omission from
458/// `scope.sync`, an explicit `scope.apply` end, replacement by a higher epoch
459/// for the same ref or expiry.
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
461pub struct ScopeEnded {
462    #[serde(rename = "ref")]
463    pub scope_ref: String,
464    pub scope_epoch: u64,
465}
466
467/// `scope.describe`'s answer about one `(owner, ref)`.
468#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
469#[serde(rename_all = "snake_case")]
470pub enum ScopeStatus {
471    Live,
472    /// Ended in this daemon incarnation, and still remembered.
473    Ended,
474    /// Neither live nor remembered as ended. With `owner_synced` true the scope
475    /// is gone: an owner's first sync of an incarnation is its full set. With
476    /// `owner_synced` false and `owner_configured` true the owner has not
477    /// re-synced since a daemon restart, so a reader waits. With
478    /// `owner_configured` false the owner will never sync.
479    NotLive,
480}
481
482/// The fields the daemon stamps for a live scope.
483/// Build with [`ScopeStamp::new`] and the setters so future fields are additive.
484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
485#[non_exhaustive]
486pub struct ScopeStamp {
487    pub owner: Principal,
488    #[serde(rename = "ref")]
489    pub scope_ref: String,
490    pub scope_epoch: u64,
491    pub kind: ScopeKind,
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub parent: Option<ScopeParent>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub parent_state: Option<ParentState>,
496    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
497    pub attributes: ScopeAttributes,
498    /// Whether the owner is listed in the daemon's `scope_authority_owners`.
499    /// Providers decide on this flag and keep no copy of the list.
500    pub owner_authorized: bool,
501    /// Absolute Unix wall-clock deadline in milliseconds, fixed for this epoch.
502    /// On route binds the daemon sends this deadline only to targets declaring
503    /// [`crate::call_mode::CALL_MODES_CAPABILITY`], because older providers may
504    /// reject unfamiliar scope-stamp fields.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub expires_at_ms: Option<u64>,
507}
508
509impl ScopeStamp {
510    /// A stamp with no parent, attributes or deadline, and no owner authority.
511    pub fn new(
512        owner: Principal,
513        scope_ref: impl Into<String>,
514        scope_epoch: u64,
515        kind: ScopeKind,
516    ) -> Self {
517        Self {
518            owner,
519            scope_ref: scope_ref.into(),
520            scope_epoch,
521            kind,
522            parent: None,
523            parent_state: None,
524            attributes: ScopeAttributes::new(),
525            owner_authorized: false,
526            expires_at_ms: None,
527        }
528    }
529
530    #[must_use]
531    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
532        self.parent = parent;
533        self
534    }
535
536    #[must_use]
537    pub fn with_parent_state(mut self, parent_state: Option<ParentState>) -> Self {
538        self.parent_state = parent_state;
539        self
540    }
541
542    #[must_use]
543    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
544        self.attributes = attributes;
545        self
546    }
547
548    #[must_use]
549    pub fn with_owner_authorized(mut self, owner_authorized: bool) -> Self {
550        self.owner_authorized = owner_authorized;
551        self
552    }
553
554    #[must_use]
555    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
556        self.expires_at_ms = expires_at_ms;
557        self
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::tool_call::OpaqueFieldError;
565
566    #[test]
567    fn flow_id_uses_the_shared_opaque_token_bounds_and_names_its_field() {
568        let field = "flow_id";
569        assert_eq!(validate_flow_id(""), Err(OpaqueFieldError::Empty { field }));
570        assert_eq!(validate_flow_id("f"), Ok(()));
571        assert_eq!(validate_flow_id(&"f".repeat(256)), Ok(()));
572        assert_eq!(
573            validate_flow_id(&"f".repeat(257)),
574            Err(OpaqueFieldError::TooLong { field, length: 257 })
575        );
576        assert_eq!(validate_flow_id("!~Flow:7/step"), Ok(()));
577        for bad in ["f é", "f\t", "fé", "f\u{7f}"] {
578            let error = validate_flow_id(bad).unwrap_err();
579            assert_eq!(
580                error,
581                OpaqueFieldError::InvalidCharacter { field, index: 1 }
582            );
583            assert_eq!(error.field(), "flow_id");
584        }
585    }
586
587    #[test]
588    fn flow_only_attributes_round_trip_and_absence_keeps_the_bytes() {
589        let attributes = ScopeAttributes::default();
590        assert!(attributes.is_empty());
591        assert_eq!(serde_json::to_string(&attributes).unwrap(), "{}");
592        assert_eq!(
593            serde_json::from_str::<ScopeAttributes>("{}").unwrap(),
594            attributes
595        );
596        let attributes = ScopeAttributes {
597            flow_id: Some("flow:7".to_string()),
598            ..ScopeAttributes::default()
599        };
600        assert!(!attributes.is_empty());
601        let encoded = serde_json::to_string(&attributes).unwrap();
602        assert_eq!(encoded, r#"{"flow_id":"flow:7"}"#);
603        assert_eq!(
604            serde_json::from_str::<ScopeAttributes>(&encoded).unwrap(),
605            attributes
606        );
607        assert!(
608            serde_json::from_str::<ScopeAttributes>(r#"{"flow_id":"flow:7","unknown":true}"#)
609                .is_err()
610        );
611    }
612}