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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
225pub struct CircuitBreaker {
226    pub identical_failures: u32,
227}
228
229/// External storage, vault, and identity bindings supplied through subc.
230#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
231pub struct Bindings {
232    pub storage: StorageBinding,
233    pub vault_grants: Vec<VaultGrant>,
234    pub identity: IdentityBinding,
235}
236
237/// Storage backend supplied by subc; the module owns its schema.
238#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
239pub struct StorageBinding {
240    pub kind: StorageKind,
241    pub scope: StorageScope,
242    pub owns_schema: bool,
243}
244
245#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
246#[serde(rename_all = "snake_case")]
247pub enum StorageKind {
248    Sqlite,
249}
250
251#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
252#[serde(rename_all = "snake_case")]
253pub enum StorageScope {
254    Project,
255}
256
257#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
258pub struct VaultGrant {
259    pub secret: String,
260    pub reason: String,
261}
262
263#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
264pub struct IdentityBinding {
265    pub requires: Vec<IdentityScope>,
266    pub optional: Vec<IdentityScope>,
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use serde_json::json;
273
274    fn aft_manifest_fixture() -> ModuleManifest {
275        ModuleManifest {
276            module_id: "aft".to_string(),
277            module_version: "0.39.2".to_string(),
278            protocol_ver: 1,
279            trust_tier: TrustTier::FirstParty,
280            provides: vec![ProviderRole::ToolProvider {
281                tools: vec![
282                    Tool {
283                        name: "read".to_string(),
284                        description: None,
285                        execution_mode: ExecutionMode::Pure,
286                        schema: json!({"type": "object"}),
287                    },
288                    Tool {
289                        name: "grep".to_string(),
290                        description: None,
291                        execution_mode: ExecutionMode::Pure,
292                        schema: json!({"type": "object"}),
293                    },
294                    Tool {
295                        name: "outline".to_string(),
296                        description: None,
297                        execution_mode: ExecutionMode::Pure,
298                        schema: json!({"type": "object"}),
299                    },
300                    Tool {
301                        name: "semantic_search".to_string(),
302                        description: None,
303                        execution_mode: ExecutionMode::Pure,
304                        schema: json!({"type": "object"}),
305                    },
306                    Tool {
307                        name: "edit".to_string(),
308                        description: None,
309                        execution_mode: ExecutionMode::Mutating,
310                        schema: json!({"type": "object"}),
311                    },
312                    Tool {
313                        name: "write".to_string(),
314                        description: None,
315                        execution_mode: ExecutionMode::Mutating,
316                        schema: json!({"type": "object"}),
317                    },
318                    Tool {
319                        name: "bash".to_string(),
320                        description: None,
321                        execution_mode: ExecutionMode::Unfenceable,
322                        schema: json!({"type": "object"}),
323                    },
324                ],
325                identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
326                concurrency: Concurrency::ModuleManaged,
327                emits_push: true,
328                sub_supervises: true,
329            }],
330            consumes: vec![ConsumerRole::ServiceClient {
331                of: vec!["embedding.v2".to_string()],
332            }],
333            scheduled_tasks: vec![],
334            bindings: Bindings {
335                storage: StorageBinding {
336                    kind: StorageKind::Sqlite,
337                    scope: StorageScope::Project,
338                    owns_schema: true,
339                },
340                vault_grants: vec![VaultGrant {
341                    secret: "provider_api_key".to_string(),
342                    reason: "cortexkit_native auth".to_string(),
343                }],
344                identity: IdentityBinding {
345                    requires: vec![IdentityScope::Project],
346                    optional: vec![IdentityScope::Session],
347                },
348            },
349        }
350    }
351
352    #[test]
353    fn serde_round_trips_representative_manifest() {
354        let manifest = aft_manifest_fixture();
355        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
356        let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
357
358        assert_eq!(manifest, decoded);
359    }
360
361    #[test]
362    fn aft_manifest_fixture_matches_v1_contract() {
363        let manifest = aft_manifest_fixture();
364
365        assert_eq!(manifest.module_id, "aft");
366        let ProviderRole::ToolProvider {
367            tools,
368            identity_scope,
369            concurrency,
370            emits_push,
371            sub_supervises,
372        } = &manifest.provides[0]
373        else {
374            panic!("AFT fixture must expose one tool_provider role");
375        };
376
377        assert_eq!(*concurrency, Concurrency::ModuleManaged);
378        assert!(*emits_push);
379        assert!(*sub_supervises);
380        assert_eq!(
381            identity_scope,
382            &vec![IdentityScope::Session, IdentityScope::Project]
383        );
384        assert_eq!(
385            tools
386                .iter()
387                .map(|tool| (tool.name.as_str(), tool.execution_mode))
388                .collect::<Vec<_>>(),
389            vec![
390                ("read", ExecutionMode::Pure),
391                ("grep", ExecutionMode::Pure),
392                ("outline", ExecutionMode::Pure),
393                ("semantic_search", ExecutionMode::Pure),
394                ("edit", ExecutionMode::Mutating),
395                ("write", ExecutionMode::Mutating),
396                ("bash", ExecutionMode::Unfenceable),
397            ]
398        );
399    }
400
401    #[test]
402    fn tool_provider_role_tag_serializes_as_snake_case() {
403        let manifest = aft_manifest_fixture();
404        let value = serde_json::to_value(&manifest).unwrap();
405
406        assert_eq!(value["provides"][0]["role"], "tool_provider");
407    }
408}