Skip to main content

subc_protocol/
manifest.rs

1//! Capability manifest schema for subc modules.
2//!
3//! All v1 modules are supervised singletons: one long-lived process per
4//! per-user machine. The manifest intentionally has **no `cardinality` field**.
5//! subc routes by module kind plus channel, while any finer demultiplexing
6//! (for example, AFT's per-project actor map) remains internal to the singleton
7//! module.
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// A module's full declared participation in the subc mesh.
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
14pub struct ModuleManifest {
15    pub module_id: String,
16    pub module_version: String,
17    pub protocol_ver: u8,
18    pub trust_tier: TrustTier,
19    pub provides: Vec<ProviderRole>,
20    pub consumes: Vec<ConsumerRole>,
21    pub scheduled_tasks: Vec<ScheduledTask>,
22    pub bindings: Bindings,
23}
24
25/// Trust gate applied by subc before routing capabilities.
26#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
27#[serde(rename_all = "snake_case")]
28pub enum TrustTier {
29    FirstParty,
30    Reviewed,
31    Untrusted,
32}
33
34/// Provider capabilities exposed by a module.
35///
36/// The role set is closed for protocol v1; unknown role tags fail serde decode.
37#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
38#[serde(tag = "role", rename_all = "snake_case")]
39pub enum ProviderRole {
40    ToolProvider {
41        tools: Vec<Tool>,
42        identity_scope: Vec<IdentityScope>,
43        concurrency: Concurrency,
44        emits_push: bool,
45        sub_supervises: bool,
46    },
47    PipelineStage {
48        stage: PipelineStageKind,
49        applies_to: PipelineAppliesTo,
50        interface: String,
51        declares_frozen_floor: bool,
52        needs_signals: Vec<String>,
53        conformance_class: String,
54    },
55    ManagementSurface {
56        operations: Vec<ManagementOperation>,
57        config_schema: Value,
58        observability: Vec<ObservabilitySurface>,
59        identity_scope: Vec<IdentityScope>,
60    },
61    InternalService {
62        service_id: String,
63        transport: InternalTransport,
64        agent_facing: bool,
65        operations: Vec<String>,
66    },
67}
68
69/// How a tool's side effects are fenced for durable at-most-once handling.
70///
71/// Classified on a tool's externally-observable effects, never inferred from
72/// the module's concurrency lane:
73/// - `Pure`: no observable side effect (reads, searches, cache warming) — safe
74///   to re-run after an indeterminate outcome.
75/// - `Mutating`: a fenceable external side effect such as a file write — a
76///   re-run risks a duplicate effect, so an indeterminate outcome must not
77///   auto-retry.
78/// - `Unfenceable`: a side effect that cannot be fenced or safely replayed,
79///   such as running a shell command — never auto-re-run on an indeterminate
80///   outcome.
81#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum ExecutionMode {
84    Pure,
85    Mutating,
86    Unfenceable,
87}
88
89/// Tool-plane capability exposed by a `tool_provider`.
90#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
91pub struct Tool {
92    pub name: String,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub description: Option<String>,
95    /// How the tool's side effects are fenced for durable at-most-once handling.
96    /// Observability + durability metadata only; subc's thin core never acts on
97    /// this for routing, scheduling, or concurrency — the module's declared
98    /// [`Concurrency`] contract governs delivery.
99    pub execution_mode: ExecutionMode,
100    pub schema: Value,
101}
102
103/// How subc may deliver concurrent in-flight calls to the provider.
104///
105/// subc records and forwards these semantics unchanged; the dispatcher that
106/// enforces them lives in subc-core, kept separate from this frozen manifest
107/// contract.
108#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
109#[serde(rename_all = "snake_case")]
110pub enum Concurrency {
111    /// One in-flight call at a time with strict submission and response order.
112    Serial,
113    /// Concurrent in-flight calls may span channels, while subc preserves FIFO
114    /// submission within each channel; the module schedules internally.
115    ModuleManaged,
116    /// Fully parallel delivery with no ordering guarantee across or within
117    /// channels.
118    StatelessParallel,
119}
120
121/// Identity keys that route or scope a call.
122#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
123#[serde(rename_all = "snake_case")]
124pub enum IdentityScope {
125    Session,
126    Project,
127}
128
129/// Proxy-plane stage kind.
130#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
131#[serde(rename_all = "snake_case")]
132pub enum PipelineStageKind {
133    Transform,
134    Codec,
135    Auth,
136}
137
138/// Provider/model selector for a pipeline stage. `"*"` denotes wildcard.
139#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
140pub struct PipelineAppliesTo {
141    pub provider: String,
142    pub model: String,
143}
144
145/// Operation exposed on the management plane.
146#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
147pub struct ManagementOperation {
148    pub name: String,
149    pub kind: ManagementOperationKind,
150}
151
152#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
153#[serde(rename_all = "snake_case")]
154pub enum ManagementOperationKind {
155    Query,
156    Mutate,
157}
158
159/// Observable state exposed on the management plane.
160#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
161pub struct ObservabilitySurface {
162    pub name: String,
163    pub kind: ObservabilityKind,
164}
165
166#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
167#[serde(rename_all = "snake_case")]
168pub enum ObservabilityKind {
169    Snapshot,
170    Stream,
171}
172
173#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
174#[serde(rename_all = "snake_case")]
175pub enum InternalTransport {
176    Bulk,
177}
178
179/// Consumer capabilities requested by a module.
180#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
181#[serde(tag = "role", rename_all = "snake_case")]
182pub enum ConsumerRole {
183    ToolClient { of: Vec<String> },
184    LlmClient { via: String, auth: String },
185    ServiceClient { of: Vec<String> },
186}
187
188/// Scheduler-owned task declaration. The runner module executes the loop; subc
189/// owns eligibility checks and the lease.
190#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
191pub struct ScheduledTask {
192    pub task_id: String,
193    pub eligibility: TaskEligibility,
194    pub lease_scope: LeaseScope,
195    pub renews_during_calls: bool,
196    pub toolset: Vec<String>,
197    pub model_policy: ModelPolicy,
198    pub step_cap: u32,
199    pub circuit_breaker: CircuitBreaker,
200}
201
202/// Time/window gates for a scheduled task. Values are serialized policy strings
203/// (for example, durations or cron/window expressions) owned by the scheduler.
204#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
205pub struct TaskEligibility {
206    pub cooldown: String,
207    pub window: String,
208}
209
210/// Scope at which subc enforces one active scheduler lease.
211#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
212#[serde(rename_all = "snake_case")]
213pub enum LeaseScope {
214    Project,
215}
216
217/// Model selection policy for the LLM-runner that executes a scheduled task.
218#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
219pub struct ModelPolicy {
220    pub tier: String,
221    pub fallback_chain: Vec<String>,
222}
223
224/// Declared trip threshold for a scheduled task's circuit breaker: stop after this
225/// many IDENTICAL consecutive failures.
226///
227/// SCOPE, because the name invites a wider reading than the field supports. The
228/// alarm condition here is "this failure looks like the last one", so it detects a
229/// task stuck failing the SAME way and is silent on a task failing MANY DIFFERENT
230/// ways -- and it goes quieter the more varied the failures become, which is often
231/// the more alarming case. A module treating this as its only stop condition will
232/// find it mutest during the messiest outage. Pair it with a signal that counts
233/// failures regardless of their kind.
234///
235/// The daemon carries this field and does not act on it: enforcement belongs to the
236/// module running the task, since only it can compare two failures for identity.
237#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
238pub struct CircuitBreaker {
239    pub identical_failures: u32,
240}
241
242/// External storage, vault, and identity bindings supplied through subc.
243#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
244pub struct Bindings {
245    pub storage: StorageBinding,
246    pub vault_grants: Vec<VaultGrant>,
247    pub identity: IdentityBinding,
248}
249
250/// Storage backend supplied by subc; the module owns its schema.
251#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
252pub struct StorageBinding {
253    pub kind: StorageKind,
254    pub scope: StorageScope,
255    pub owns_schema: bool,
256}
257
258#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
259#[serde(rename_all = "snake_case")]
260pub enum StorageKind {
261    Sqlite,
262}
263
264#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
265#[serde(rename_all = "snake_case")]
266pub enum StorageScope {
267    Project,
268}
269
270#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
271pub struct VaultGrant {
272    pub secret: String,
273    pub reason: String,
274}
275
276#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
277pub struct IdentityBinding {
278    pub requires: Vec<IdentityScope>,
279    pub optional: Vec<IdentityScope>,
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use serde_json::json;
286
287    fn aft_manifest_fixture() -> ModuleManifest {
288        ModuleManifest {
289            module_id: "aft".to_string(),
290            module_version: "0.39.2".to_string(),
291            protocol_ver: 1,
292            trust_tier: TrustTier::FirstParty,
293            provides: vec![ProviderRole::ToolProvider {
294                tools: vec![
295                    Tool {
296                        name: "read".to_string(),
297                        description: None,
298                        execution_mode: ExecutionMode::Pure,
299                        schema: json!({"type": "object"}),
300                    },
301                    Tool {
302                        name: "grep".to_string(),
303                        description: None,
304                        execution_mode: ExecutionMode::Pure,
305                        schema: json!({"type": "object"}),
306                    },
307                    Tool {
308                        name: "outline".to_string(),
309                        description: None,
310                        execution_mode: ExecutionMode::Pure,
311                        schema: json!({"type": "object"}),
312                    },
313                    Tool {
314                        name: "semantic_search".to_string(),
315                        description: None,
316                        execution_mode: ExecutionMode::Pure,
317                        schema: json!({"type": "object"}),
318                    },
319                    Tool {
320                        name: "edit".to_string(),
321                        description: None,
322                        execution_mode: ExecutionMode::Mutating,
323                        schema: json!({"type": "object"}),
324                    },
325                    Tool {
326                        name: "write".to_string(),
327                        description: None,
328                        execution_mode: ExecutionMode::Mutating,
329                        schema: json!({"type": "object"}),
330                    },
331                    Tool {
332                        name: "bash".to_string(),
333                        description: None,
334                        execution_mode: ExecutionMode::Unfenceable,
335                        schema: json!({"type": "object"}),
336                    },
337                ],
338                identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
339                concurrency: Concurrency::ModuleManaged,
340                emits_push: true,
341                sub_supervises: true,
342            }],
343            consumes: vec![ConsumerRole::ServiceClient {
344                of: vec!["embedding.v2".to_string()],
345            }],
346            scheduled_tasks: vec![],
347            bindings: Bindings {
348                storage: StorageBinding {
349                    kind: StorageKind::Sqlite,
350                    scope: StorageScope::Project,
351                    owns_schema: true,
352                },
353                vault_grants: vec![VaultGrant {
354                    secret: "provider_api_key".to_string(),
355                    reason: "cortexkit_native auth".to_string(),
356                }],
357                identity: IdentityBinding {
358                    requires: vec![IdentityScope::Project],
359                    optional: vec![IdentityScope::Session],
360                },
361            },
362        }
363    }
364
365    #[test]
366    fn serde_round_trips_representative_manifest() {
367        let manifest = aft_manifest_fixture();
368        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
369        let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
370
371        assert_eq!(manifest, decoded);
372    }
373
374    #[test]
375    fn aft_manifest_fixture_matches_v1_contract() {
376        let manifest = aft_manifest_fixture();
377
378        assert_eq!(manifest.module_id, "aft");
379        let ProviderRole::ToolProvider {
380            tools,
381            identity_scope,
382            concurrency,
383            emits_push,
384            sub_supervises,
385        } = &manifest.provides[0]
386        else {
387            panic!("AFT fixture must expose one tool_provider role");
388        };
389
390        assert_eq!(*concurrency, Concurrency::ModuleManaged);
391        assert!(*emits_push);
392        assert!(*sub_supervises);
393        assert_eq!(
394            identity_scope,
395            &vec![IdentityScope::Session, IdentityScope::Project]
396        );
397        assert_eq!(
398            tools
399                .iter()
400                .map(|tool| (tool.name.as_str(), tool.execution_mode))
401                .collect::<Vec<_>>(),
402            vec![
403                ("read", ExecutionMode::Pure),
404                ("grep", ExecutionMode::Pure),
405                ("outline", ExecutionMode::Pure),
406                ("semantic_search", ExecutionMode::Pure),
407                ("edit", ExecutionMode::Mutating),
408                ("write", ExecutionMode::Mutating),
409                ("bash", ExecutionMode::Unfenceable),
410            ]
411        );
412    }
413
414    #[test]
415    fn tool_provider_role_tag_serializes_as_snake_case() {
416        let manifest = aft_manifest_fixture();
417        let value = serde_json::to_value(&manifest).unwrap();
418
419        assert_eq!(value["provides"][0]["role"], "tool_provider");
420    }
421}