subc-protocol 0.30.2

Shared wire contract for subc <-> modules: the 21-byte envelope, the Frame (header + opaque body), channel-0 control bodies, route.bind/RouteTarget session shapes, and the capability manifest. Single source of truth, depended on by subc-core and AFT.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
//! Scope records: owned identity records the daemon holds for sessions.
//!
//! A scope is identified by `(owner, ref)`. The owner is the module whose own
//! registered connection synced it, never a value in the request, and the
//! `ref` is an opaque string unique within that owner only. The design is
//! `docs/designs/daemon-scopes.md`; the wire shapes here are the owner-facing
//! half of it (`scope.sync`, `scope.apply`, `scope.describe`).
//!
//! Every record type refuses unknown fields. A field this daemon does not know
//! may be one that narrows authority in a later version (a carrier's target
//! list, say), and silently dropping it would widen what the scope grants, so
//! an owner sending one is told its body is malformed instead.

use serde::{Deserialize, Serialize};

use crate::Principal;

// Scope principals are authority-bearing input: an unrecognized constraint must
// not silently widen a grant. Principal elsewhere is a forward-compatible caller
// fact, so keep its general decoder lenient and enforce this only on scope input.
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum ScopePrincipal {
    Reserved { module_id: String },
    Direct {},
    Unverified {},
}

impl From<ScopePrincipal> for Principal {
    fn from(value: ScopePrincipal) -> Self {
        match value {
            ScopePrincipal::Reserved { module_id } => Self::Reserved { module_id },
            ScopePrincipal::Direct {} => Self::Direct,
            ScopePrincipal::Unverified {} => Self::Unverified,
        }
    }
}

fn deserialize_scope_principal<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<Principal, D::Error> {
    ScopePrincipal::deserialize(deserializer).map(Into::into)
}

fn deserialize_scope_principals<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<Vec<Principal>, D::Error> {
    Vec::<ScopePrincipal>::deserialize(deserializer)
        .map(|principals| principals.into_iter().map(Into::into).collect())
}

/// The `server.describe` capability a daemon advertises when it admits routes
/// under scopes. A carrier that needs a scoped route and does not see it fails
/// the call (`scope_unsupported`) instead of opening an unscoped route.
pub const CAP_SCOPES_V1: &str = "scopes/v1";

/// The `server.describe` capability a daemon advertises when it checks a
/// `route.open`'s `role_versions` and forwards them on the module's bind. A
/// daemon without it drops the field silently, so a consumer that relies on
/// the provider seeing its role versions checks for this first.
pub const CAP_ROUTE_ROLE_VERSIONS_V1: &str = "route-role-versions/v1";

/// Module-to-subc op that registers an owner's full scope set.
pub const SCOPE_SYNC_OP: &str = "scope.sync";
/// Module-to-subc op that upserts or ends scopes without replacing the owner's
/// full scope set.
pub const SCOPE_APPLY_OP: &str = "scope.apply";
/// Module-to-subc op that reads one scope's current state.
pub const SCOPE_DESCRIBE_OP: &str = "scope.describe";

/// Most live scopes one owner may hold. A sync naming more is refused whole.
pub const MAX_LIVE_SCOPES_PER_OWNER: usize = 10_000;
/// Most bytes one scope's `attributes` may take, measured as compact JSON. A
/// sync carrying a larger record is refused whole.
pub const MAX_SCOPE_ATTRIBUTE_BYTES: usize = 4 * 1024;
/// Most ended scopes the daemon remembers per owner. The oldest is forgotten
/// first, and reaching the bound never refuses a sync.
pub const MAX_SCOPE_TOMBSTONES_PER_OWNER: usize = 1_000;
/// Most modules one targeted carrier entry may list.
pub const MAX_CARRIER_TARGETS: usize = 16;
/// Most milliseconds a new scope epoch's deadline may be ahead of the daemon's
/// current Unix wall clock: 24 hours.
pub const MAX_SCOPE_EXPIRY_AHEAD_MS: u64 = 86_400_000;

/// What a scope stands for. Closed, and fixed for the life of one
/// `scope_epoch`: a different kind needs a new epoch, which ends the old scope.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ScopeKind {
    Head,
    Worker,
    Ephemeral,
}

/// A link from a scope to another scope, pinned to that scope's session by its
/// epoch.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeParent {
    #[serde(deserialize_with = "deserialize_scope_principal")]
    pub owner: Principal,
    #[serde(rename = "ref")]
    pub scope_ref: String,
    pub scope_epoch: u64,
}

impl ScopeParent {
    pub fn new(owner: Principal, scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
        Self {
            owner,
            scope_ref: scope_ref.into(),
            scope_epoch,
        }
    }
}

/// Who, besides the owner, may open routes under a scope.
///
/// `targets` absent means the carrier may open to any module. Present, it
/// names the only module ids the carrier may open to, and must hold between 1
/// and [`MAX_CARRIER_TARGETS`] entries: an empty list is refused rather than
/// read as either "none" or "all".
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeCarrier {
    #[serde(deserialize_with = "deserialize_scope_principal")]
    pub principal: Principal,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub targets: Option<Vec<String>>,
}

impl ScopeCarrier {
    pub fn new(principal: Principal) -> Self {
        Self {
            principal,
            targets: None,
        }
    }

    #[must_use]
    pub fn with_targets(mut self, targets: Option<Vec<String>>) -> Self {
        self.targets = targets;
        self
    }
}

/// Declaring this in a manifest's `capabilities.provides` promises that the
/// module recognises a scope carrying `flow_id` and applies flow behaviour:
/// it never treats the flow as its owner agent.
pub const FLOW_SCOPES_CAPABILITY: &str = "flow-scopes/v1";

/// Declaring this in a manifest's `capabilities.provides` promises that the
/// module recognises `run_id` as one agent run and does not exercise the agent's
/// delegated authority under that scope.
pub const AGENT_RUN_SCOPES_CAPABILITY: &str = "agent-run-scopes/v1";

/// The authority attributes the daemon copies into scope stamps unchanged.
/// Only an owner module named in the daemon config's `scope_authority_owners`
/// list (by default the module that owns agent sessions) may set them; a scope
/// owned by any other module must leave them empty.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeAttributes {
    /// The agent the scope's session belongs to. Identity, never permission.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    /// Whether a provider may act as `agent_id`. Refused without `agent_id`.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub delegates: bool,
    /// This scope belongs to the named flow, an automated workflow run on an
    /// agent's behalf. Set only by an authority owner (see above), validated
    /// with [`validate_flow_id`], and stamped verbatim. It needs neither
    /// `agent_id` nor `delegates`. Providers treat a module other than the
    /// owner that opens a route under a flow scope as the flow's carrier.
    ///
    /// Like an `agent_id` change, changing this within the same scope epoch is
    /// accepted: the scope's content version increases (the number providers
    /// compare to notice a change), and every route under the scope is closed
    /// with the reason `scope_delegation_changed`, so no live route keeps the
    /// old identity.
    /// A target must provide [`FLOW_SCOPES_CAPABILITY`] before the daemon may
    /// send a bind stamped with this field. Decoding it alone is not enough:
    /// the target must also apply flow behaviour instead of agent behaviour.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub flow_id: Option<String>,
    /// Identifies one run carried out on behalf of `agent_id`. Requires
    /// `agent_id`, forbids `flow_id` and `delegates`, and uses
    /// [`validate_run_id`]'s token rule.
    /// Adding, changing or removing it within an epoch increments the scope's
    /// content version and closes routes under the scope with
    /// `scope_delegation_changed`. The daemon sends this field in a bind only to
    /// a target module providing [`AGENT_RUN_SCOPES_CAPABILITY`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run_id: Option<String>,
}

impl ScopeAttributes {
    pub fn new() -> Self {
        Self::default()
    }

    #[must_use]
    pub fn with_agent_id(mut self, agent_id: Option<String>) -> Self {
        self.agent_id = agent_id;
        self
    }

    #[must_use]
    pub fn with_delegates(mut self, delegates: bool) -> Self {
        self.delegates = delegates;
        self
    }

    #[must_use]
    pub fn with_flow_id(mut self, flow_id: Option<String>) -> Self {
        self.flow_id = flow_id;
        self
    }

    #[must_use]
    pub fn with_run_id(mut self, run_id: Option<String>) -> Self {
        self.run_id = run_id;
        self
    }

    pub fn is_empty(&self) -> bool {
        self.agent_id.is_none()
            && !self.delegates
            && self.flow_id.is_none()
            && self.run_id.is_none()
    }
}

/// Check a flow id using the shared opaque-token rule: 1–256 printable,
/// non-space ASCII bytes. Errors name `flow_id`. Scope refs remain opaque and
/// are not subject to this token rule.
pub fn validate_flow_id(flow_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
    crate::tool_call::validate_opaque_field("flow_id", flow_id)
}

/// Check a run id using the shared opaque-token rule: 1–256 ASCII bytes in
/// `0x21`–`0x7E`. Errors name `run_id`.
pub fn validate_run_id(run_id: &str) -> Result<(), crate::tool_call::OpaqueFieldError> {
    crate::tool_call::validate_opaque_field("run_id", run_id)
}

/// One scope as its owner registers it in `scope.sync` or `scope.apply`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeRecord {
    #[serde(rename = "ref")]
    pub scope_ref: String,
    /// Owner-supplied session number. The owner keeps it with its own record of
    /// the session, re-sends the same value for the same session after any
    /// restart, and uses a higher one when it reuses the ref for a new session.
    pub scope_epoch: u64,
    pub kind: ScopeKind,
    /// Absolute Unix wall-clock deadline in milliseconds. It cannot be added,
    /// changed or removed within this scope epoch; absent means no deadline.
    /// This is not an authority attribute.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires_at_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent: Option<ScopeParent>,
    /// Principals, other than the owner, allowed to register child scopes under
    /// this one. Listing a principal here grants it nothing else.
    #[serde(
        default,
        skip_serializing_if = "Vec::is_empty",
        deserialize_with = "deserialize_scope_principals"
    )]
    pub child_owners: Vec<Principal>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub carriers: Vec<ScopeCarrier>,
    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
    pub attributes: ScopeAttributes,
}

impl ScopeRecord {
    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, kind: ScopeKind) -> Self {
        Self {
            scope_ref: scope_ref.into(),
            scope_epoch,
            kind,
            expires_at_ms: None,
            parent: None,
            child_owners: Vec::new(),
            carriers: Vec::new(),
            attributes: ScopeAttributes::default(),
        }
    }

    #[must_use]
    pub fn with_expires_at_ms(mut self, expires_at_ms: Option<u64>) -> Self {
        self.expires_at_ms = expires_at_ms;
        self
    }

    #[must_use]
    pub fn with_parent(mut self, parent: Option<ScopeParent>) -> Self {
        self.parent = parent;
        self
    }

    #[must_use]
    pub fn with_child_owners(mut self, child_owners: Vec<Principal>) -> Self {
        self.child_owners = child_owners;
        self
    }

    #[must_use]
    pub fn with_carriers(mut self, carriers: Vec<ScopeCarrier>) -> Self {
        self.carriers = carriers;
        self
    }

    #[must_use]
    pub fn with_attributes(mut self, attributes: ScopeAttributes) -> Self {
        self.attributes = attributes;
        self
    }
}

/// A request to end one scope session in `scope.apply`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeEnd {
    #[serde(rename = "ref")]
    pub scope_ref: String,
    pub scope_epoch: u64,
}

impl ScopeEnd {
    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64) -> Self {
        Self {
            scope_ref: scope_ref.into(),
            scope_epoch,
        }
    }
}

/// What `scope.apply` did with one requested end.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScopeEndOutcome {
    /// The named scope ref was live at this epoch and was ended.
    Ended,
    /// The named scope ref was not live at this epoch; this end request changed
    /// nothing.
    NotLive,
}

/// The per-end result in a `scope.apply` reply, in request order.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ScopeEndResult {
    #[serde(rename = "ref")]
    pub scope_ref: String,
    pub scope_epoch: u64,
    pub outcome: ScopeEndOutcome,
}

impl ScopeEndResult {
    pub fn new(scope_ref: impl Into<String>, scope_epoch: u64, outcome: ScopeEndOutcome) -> Self {
        Self {
            scope_ref: scope_ref.into(),
            scope_epoch,
            outcome,
        }
    }
}

/// The scope a `route.open` asks to be admitted under.
///
/// `scope_epoch` is optional on the wire only so that leaving it out is
/// refused by name (`scope_epoch_required`) rather than as a malformed body:
/// every opener must name it, the owner included.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ScopeSelector {
    #[serde(deserialize_with = "deserialize_scope_principal")]
    pub owner: Principal,
    #[serde(rename = "ref")]
    pub scope_ref: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scope_epoch: Option<u64>,
}

/// The state of a scope's parent link.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ParentState {
    /// The parent is live at the named epoch and the link was permitted.
    Linked,
    /// The parent's owner has not synced in this daemon incarnation, so the
    /// link is unverified and grants nothing yet.
    Pending,
    /// The parent is gone or live at another epoch, or the link was refused
    /// when the parent's owner synced. Final for this link.
    Ended,
}

/// What a `scope.sync` or `scope.apply` did with one record.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScopeRecordOutcome {
    /// The ref was not live before; the scope was created.
    Created,
    /// The ref was live at a lower epoch; that scope ended and this one began.
    Replaced,
    /// The ref was live at this epoch and its content changed.
    Updated,
    /// The ref was live at this epoch with identical content; its `version`
    /// did not move.
    Unchanged,
    /// The record was refused on its own merits (`code` says why). The refusal
    /// itself does not change the ref or undo any expiry processing.
    Refused,
}

/// The per-record result in a `scope.sync` or `scope.apply` reply, in request order.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScopeRecordResult {
    #[serde(rename = "ref")]
    pub scope_ref: String,
    /// The epoch the record named, which for a refusal may differ from the
    /// epoch still held.
    pub scope_epoch: u64,
    pub outcome: ScopeRecordOutcome,
    /// The refusal code when `outcome` is `refused`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// The daemon's content counter for the scope held under this ref after
    /// the sync; absent when no scope is live under it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<u64>,
    /// The parent link's state after the sync, for a scope with a parent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_state: Option<ParentState>,
}

/// A scope session of the calling owner that ended through omission from
/// `scope.sync`, an explicit `scope.apply` end, replacement by a higher epoch
/// for the same ref or expiry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScopeEnded {
    #[serde(rename = "ref")]
    pub scope_ref: String,
    pub scope_epoch: u64,
}

/// `scope.describe`'s answer about one `(owner, ref)`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScopeStatus {
    Live,
    /// Ended in this daemon incarnation, and still remembered.
    Ended,
    /// Neither live nor remembered as ended. With `owner_synced` true the scope
    /// is gone: an owner's first sync of an incarnation is its full set. With
    /// `owner_synced` false and `owner_configured` true the owner has not
    /// re-synced since a daemon restart, so a reader waits. With
    /// `owner_configured` false the owner will never sync.
    NotLive,
}

/// The fields the daemon stamps for a live scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScopeStamp {
    pub owner: Principal,
    #[serde(rename = "ref")]
    pub scope_ref: String,
    pub scope_epoch: u64,
    pub kind: ScopeKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent: Option<ScopeParent>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_state: Option<ParentState>,
    #[serde(default, skip_serializing_if = "ScopeAttributes::is_empty")]
    pub attributes: ScopeAttributes,
    /// Whether the owner is listed in the daemon's `scope_authority_owners`.
    /// Providers decide on this flag and keep no copy of the list.
    pub owner_authorized: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tool_call::OpaqueFieldError;

    #[test]
    fn flow_id_uses_the_shared_opaque_token_bounds_and_names_its_field() {
        let field = "flow_id";
        assert_eq!(validate_flow_id(""), Err(OpaqueFieldError::Empty { field }));
        assert_eq!(validate_flow_id("f"), Ok(()));
        assert_eq!(validate_flow_id(&"f".repeat(256)), Ok(()));
        assert_eq!(
            validate_flow_id(&"f".repeat(257)),
            Err(OpaqueFieldError::TooLong { field, length: 257 })
        );
        assert_eq!(validate_flow_id("!~Flow:7/step"), Ok(()));
        for bad in ["f é", "f\t", "fé", "f\u{7f}"] {
            let error = validate_flow_id(bad).unwrap_err();
            assert_eq!(
                error,
                OpaqueFieldError::InvalidCharacter { field, index: 1 }
            );
            assert_eq!(error.field(), "flow_id");
        }
    }

    #[test]
    fn flow_only_attributes_round_trip_and_absence_keeps_the_bytes() {
        let attributes = ScopeAttributes::default();
        assert!(attributes.is_empty());
        assert_eq!(serde_json::to_string(&attributes).unwrap(), "{}");
        assert_eq!(
            serde_json::from_str::<ScopeAttributes>("{}").unwrap(),
            attributes
        );
        let attributes = ScopeAttributes {
            flow_id: Some("flow:7".to_string()),
            ..ScopeAttributes::default()
        };
        assert!(!attributes.is_empty());
        let encoded = serde_json::to_string(&attributes).unwrap();
        assert_eq!(encoded, r#"{"flow_id":"flow:7"}"#);
        assert_eq!(
            serde_json::from_str::<ScopeAttributes>(&encoded).unwrap(),
            attributes
        );
        assert!(
            serde_json::from_str::<ScopeAttributes>(r#"{"flow_id":"flow:7","unknown":true}"#)
                .is_err()
        );
    }
}