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 std::{collections::HashSet, fmt};
10
11use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
12use serde_json::Value;
13
14use crate::PROTOCOL_VERSION;
15
16/// A module's full declared participation in the subc mesh.
17///
18/// Construct via [`ModuleManifest::builder()`], never a struct literal. Adding a
19/// field to this struct would break every direct construction site; builder methods
20/// are additive, so constructors written against an older revision continue
21/// compiling when later fields land.
22#[derive(Serialize, Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub struct ModuleManifest {
25    pub module_id: String,
26    pub module_version: String,
27    pub protocol_ver: u8,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub trust_tier: Option<TrustTier>,
30    /// Whether this registered module is ready to receive new route binds.
31    ///
32    /// Absence means ready, preserving the behavior of manifests emitted before
33    /// this field existed. This is a best-effort admission hint, not an invariant:
34    /// the daemon reads readiness separately from reserving a relay, so modules
35    /// must still tolerate an `on_bind` while not ready.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub ready: Option<bool>,
38    /// Existing role declarations; capability grammar claims deliberately live in
39    /// the separate [`CapabilityDeclarations`] block below.
40    pub provides: Vec<ProviderRole>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub consumes: Vec<ConsumerRole>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub bindings: Option<Bindings>,
45    /// Optional capability-grammar declarations.
46    ///
47    /// Omitting this block preserves the manifest contract used before capability
48    /// grammar was introduced. A present block is static discovery metadata that
49    /// the daemon validates before accepting a HELLO.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub capabilities: Option<CapabilityDeclarations>,
52    /// Events published as notices on `ck.{acct}.event.{module_id}.{event}.v{N}`.
53    /// The publisher serves the body; declared headers are filter hints, never authority.
54    ///
55    /// `None` means the module has not declared events. `Some(vec![])` affirmatively
56    /// declares that it publishes none. Absence remains absent on the wire.
57    ///
58    /// Declarations are validated at HELLO and retained in the daemon registry,
59    /// but are not yet served by `catalog.list`, `server.describe`, or `ck catalog`.
60    /// Serving will land with the next subc-control minor release. Older daemons
61    /// drop this field: deploy readers first, and do not rely on declarations
62    /// until a daemon supporting their discovery is running.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub events: Option<Vec<EventDeclaration>>,
65    /// Periodic or event-driven behavior this module performs against an external
66    /// surface, so later analysts can account for the resulting self-shaped time
67    /// series.
68    ///
69    /// Declarations describe the EFFECTIVE values in force at HELLO time. In
70    /// particular, a compile-time cadence constant belongs in
71    /// [`SignalCadence::Literal`], while a cadence resolved from configuration
72    /// belongs in [`SignalCadence::Derived`] with a pointer to that effective
73    /// source. Both provenance stories are honest; copying a stale configured
74    /// value into a literal is not.
75    ///
76    /// Ephemeral signals are out of scope for v1 because they are not durably
77    /// declarable, not because they are harmless. A v2 reader must not interpret
78    /// this field's absence in a v1 manifest as a judgement about ephemerals.
79    ///
80    /// `None` and `Some(vec![])` are deliberately distinct on the wire: an
81    /// absent block means the module has not adopted this vocabulary (readers
82    /// treat it as zero signals but must not treat it as a survey answer),
83    /// while an empty list is an affirmative declaration that the module
84    /// examined its effects and claims none are declarable. Modules that mean
85    /// "no signals" should declare the empty list; `None` is what an
86    /// un-adopted manifest looks like, not a statement.
87    ///
88    /// Convention for mutate-effect entries: where the mutation leaves a
89    /// per-observation tell on the surface itself (insula publishes the
90    /// relaxed `usedPercent` beside the raw figure, so any single reading
91    /// self-reports whether it was touched), name that tell in the
92    /// declaration's note. A standing registry row says the module sometimes
93    /// mutates; the tell says whether THIS observation was mutated — a
94    /// consumer holding one sample can act on the second, not the first.
95    /// A named tell must exist on the wire independently of this registry:
96    /// the declaration points at evidence, it is never the evidence. A tell
97    /// that exists only because the manifest describes it is a claim
98    /// vouching for itself.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub self_signals: Option<Vec<SelfSignalDeclaration>>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub provenance: Option<ManifestProvenance>,
103}
104
105/// Incrementally constructs a [`ModuleManifest`] without fabricating absent facts.
106#[derive(Debug, Clone)]
107pub struct ModuleManifestBuilder {
108    module_id: String,
109    module_version: String,
110    protocol_ver: u8,
111    trust_tier: Option<TrustTier>,
112    ready: Option<bool>,
113    provides: Vec<ProviderRole>,
114    consumes: Vec<ConsumerRole>,
115    bindings: Option<Bindings>,
116    capabilities: Option<CapabilityDeclarations>,
117    events: Option<Vec<EventDeclaration>>,
118    self_signals: Option<Vec<SelfSignalDeclaration>>,
119    provenance: Option<ManifestProvenance>,
120}
121
122impl ModuleManifest {
123    /// Starts a manifest builder with the minimal identification fields.
124    ///
125    /// `protocol_ver` defaults to the protocol version linked into this crate;
126    /// `provides` and `consumes` default to empty declarations. `trust_tier`
127    /// and `bindings` default to `None` because the daemon reads neither on any
128    /// production path; a required unread field forces producers to invent
129    /// fabricated values.
130    pub fn builder(
131        module_id: impl Into<String>,
132        module_version: impl Into<String>,
133    ) -> ModuleManifestBuilder {
134        ModuleManifestBuilder {
135            module_id: module_id.into(),
136            module_version: module_version.into(),
137            protocol_ver: PROTOCOL_VERSION,
138            trust_tier: None,
139            ready: None,
140            provides: Vec::new(),
141            consumes: Vec::new(),
142            bindings: None,
143            capabilities: None,
144            events: None,
145            self_signals: None,
146            provenance: None,
147        }
148    }
149}
150
151impl ModuleManifestBuilder {
152    /// Overrides the linked protocol version for compatibility fixtures.
153    pub fn protocol_ver(mut self, protocol_ver: u8) -> Self {
154        self.protocol_ver = protocol_ver;
155        self
156    }
157
158    /// Declares the optional trust tier of this module.
159    ///
160    /// The daemon does not evaluate this field on any production path.
161    pub fn trust_tier(mut self, trust_tier: Option<TrustTier>) -> Self {
162        self.trust_tier = trust_tier;
163        self
164    }
165
166    /// Declares whether the module is ready to receive new route binds.
167    pub fn ready(mut self, ready: bool) -> Self {
168        self.ready = Some(ready);
169        self
170    }
171
172    /// Declares the provider roles this module exposes.
173    pub fn provides(mut self, provides: Vec<ProviderRole>) -> Self {
174        self.provides = provides;
175        self
176    }
177
178    /// Declares the consumer roles this module requests.
179    pub fn consumes(mut self, consumes: Vec<ConsumerRole>) -> Self {
180        self.consumes = consumes;
181        self
182    }
183
184    /// Declares the optional resource and subsystem bindings of this module.
185    ///
186    /// The daemon does not evaluate this field on any production path.
187    pub fn bindings(mut self, bindings: Option<Bindings>) -> Self {
188        self.bindings = bindings;
189        self
190    }
191
192    /// Adds optional capability-grammar declarations.
193    pub fn capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
194        self.capabilities = capabilities;
195        self
196    }
197
198    /// Adds optional bus event declarations; an empty list declares no events.
199    pub fn events(mut self, events: Option<Vec<EventDeclaration>>) -> Self {
200        self.events = events;
201        self
202    }
203
204    /// Adds optional periodic or event-driven behavior declarations.
205    pub fn self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
206        self.self_signals = self_signals;
207        self
208    }
209
210    /// Adds optional build provenance declared by the module.
211    pub fn provenance(mut self, provenance: Option<ManifestProvenance>) -> Self {
212        self.provenance = provenance;
213        self
214    }
215
216    /// Finishes the manifest.
217    pub fn build(self) -> ModuleManifest {
218        ModuleManifest {
219            module_id: self.module_id,
220            module_version: self.module_version,
221            protocol_ver: self.protocol_ver,
222            trust_tier: self.trust_tier,
223            ready: self.ready,
224            provides: self.provides,
225            consumes: self.consumes,
226            bindings: self.bindings,
227            capabilities: self.capabilities,
228            events: self.events,
229            self_signals: self.self_signals,
230            provenance: self.provenance,
231        }
232    }
233}
234
235/// DELIBERATELY LENIENT: unknown top-level manifest keys are DROPPED at this
236/// parse boundary, not rejected and not retained. This is forward
237/// compatibility across version skew — a module built against a newer
238/// subc-protocol must still HELLO into an older daemon, and strictness here
239/// would turn every additive manifest field into a daemon-first flag day.
240/// The costs, so nobody re-derives them the hard way (CEREB found both):
241/// - A key you add module-side is INVISIBLE to the daemon until a typed field
242///   lands here. Producing it is honest; assuming a daemon-side reader exists
243///   is not. Say who the audience is next to any such producer.
244/// - There is deliberately NO untyped extension bag on this struct: a
245///   retained-verbatim Value map becomes an unversioned de-facto wire
246///   contract nobody authored (the drift class module-owned payload crates
247///   exist to prevent). When a daemon consumer materializes for a fact, the
248///   fact gets a typed optional field with a CONSUMER-IMPACT commit instead.
249///
250/// `CapabilityDeclarations` below is strict by contrast because claims are
251/// routed on: an unparseable claim must refuse loudly, never partially apply.
252#[derive(Deserialize)]
253struct ModuleManifestWire {
254    module_id: String,
255    module_version: String,
256    protocol_ver: u8,
257    #[serde(default)]
258    trust_tier: Option<TrustTier>,
259    #[serde(default)]
260    ready: Option<bool>,
261    provides: Vec<ProviderRole>,
262    #[serde(default)]
263    consumes: Vec<ConsumerRole>,
264    #[serde(default)]
265    bindings: Option<Bindings>,
266    #[serde(default)]
267    capabilities: Option<CapabilityDeclarations>,
268    #[serde(default)]
269    events: Option<Value>,
270    #[serde(default)]
271    self_signals: Option<Vec<SelfSignalDeclaration>>,
272    #[serde(default)]
273    provenance: Option<ManifestProvenance>,
274    // `runtime_computed` belongs to --manifest output rather than the retained
275    // manifest model. Deserialize it only long enough to enforce that capability
276    // declarations cannot be omitted as runtime-varying data.
277    #[serde(default)]
278    runtime_computed: Option<Value>,
279}
280
281impl<'de> Deserialize<'de> for ModuleManifest {
282    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283    where
284        D: Deserializer<'de>,
285    {
286        let wire = ModuleManifestWire::deserialize(deserializer)?;
287        validate_runtime_computed(wire.runtime_computed.as_ref(), "runtime_computed")
288            .map_err(D::Error::custom)?;
289        validate_events_value(wire.events.as_ref()).map_err(D::Error::custom)?;
290        let events = wire
291            .events
292            .map(serde_json::from_value)
293            .transpose()
294            .map_err(D::Error::custom)?;
295        let mut builder = Self::builder(wire.module_id, wire.module_version)
296            .protocol_ver(wire.protocol_ver)
297            .trust_tier(wire.trust_tier);
298        if let Some(ready) = wire.ready {
299            builder = builder.ready(ready);
300        }
301        let manifest = builder
302            .provides(wire.provides)
303            .consumes(wire.consumes)
304            .bindings(wire.bindings)
305            .capabilities(wire.capabilities)
306            .events(events)
307            .self_signals(wire.self_signals)
308            .provenance(wire.provenance)
309            .build();
310        manifest
311            .validate_capability_grammar()
312            .map_err(D::Error::custom)?;
313        Ok(manifest)
314    }
315}
316
317/// A versioned bus event notice whose body is served by the publisher.
318#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
319#[non_exhaustive]
320pub struct EventDeclaration {
321    /// One NATS subject token: `[a-z0-9][a-z0-9_]{0,62}`.
322    ///
323    /// This is the event token rule in `cortexkit-bus-naming` 0.2.0,
324    /// [`token.rs`, lines 91–143](https://docs.rs/cortexkit-bus-naming/0.2.0/src/cortexkit_bus_naming/token.rs.html#91-143).
325    pub name: String,
326    /// Event schema version, at least 1.
327    pub version: u32,
328    /// Header names every notice carries, for filtering only, never authority.
329    /// Names match `[a-z][a-z0-9_]{0,31}` and are unique within the declaration.
330    pub headers: Vec<String>,
331    /// Short human line for consent cards, at most 200 characters without controls.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub summary: Option<String>,
334}
335
336impl EventDeclaration {
337    /// Starts an event declaration with no filter headers or summary.
338    /// Validation happens when decoding a manifest or admitting its HELLO.
339    pub fn new(name: impl Into<String>, version: u32) -> Self {
340        Self {
341            name: name.into(),
342            version,
343            headers: Vec::new(),
344            summary: None,
345        }
346    }
347
348    /// Sets the header names carried by every notice of this event.
349    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
350        self.headers = headers;
351        self
352    }
353
354    /// Sets or removes the short human summary.
355    pub fn with_summary(mut self, summary: Option<String>) -> Self {
356        self.summary = summary;
357        self
358    }
359}
360
361/// A safe-to-report event declaration failure, without echoing untrusted values.
362#[derive(Debug, Clone, PartialEq, Eq)]
363#[non_exhaustive]
364pub struct EventDeclarationError {
365    field: String,
366    reason: &'static str,
367}
368
369impl EventDeclarationError {
370    fn new(field: impl Into<String>, reason: &'static str) -> Self {
371        Self {
372            field: field.into(),
373            reason,
374        }
375    }
376
377    /// The precise malformed field path, relative to the manifest.
378    pub fn field(&self) -> &str {
379        &self.field
380    }
381}
382
383impl fmt::Display for EventDeclarationError {
384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385        write!(
386            f,
387            "invalid event declaration: field {} {}",
388            self.field, self.reason
389        )
390    }
391}
392
393impl std::error::Error for EventDeclarationError {}
394
395/// Validates event declarations before HELLO decoding can lose field context.
396pub fn validate_hello_event_declarations(hello: &Value) -> Result<(), EventDeclarationError> {
397    validate_events_value(
398        hello
399            .get("manifest")
400            .and_then(|manifest| manifest.get("events")),
401    )
402}
403
404fn is_valid_event_name(name: &str) -> bool {
405    (1..=63).contains(&name.len())
406        && name.as_bytes()[0].is_ascii_alphanumeric()
407        && name
408            .bytes()
409            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
410}
411
412fn validate_events_value(value: Option<&Value>) -> Result<(), EventDeclarationError> {
413    let Some(value) = value.filter(|value| !value.is_null()) else {
414        return Ok(());
415    };
416    let entries = value
417        .as_array()
418        .ok_or_else(|| EventDeclarationError::new("events", "must be a list"))?;
419    if entries.len() > 64 {
420        return Err(EventDeclarationError::new(
421            "events",
422            "must contain at most 64 declarations",
423        ));
424    }
425    let mut pairs = HashSet::new();
426    for (index, entry) in entries.iter().enumerate() {
427        let field = format!("events[{index}]");
428        let entry = entry
429            .as_object()
430            .ok_or_else(|| EventDeclarationError::new(&field, "must be an object"))?;
431        let name_field = format!("{field}.name");
432        let name = entry
433            .get("name")
434            .and_then(Value::as_str)
435            .ok_or_else(|| EventDeclarationError::new(&name_field, "must be a string"))?;
436        if !is_valid_event_name(name) {
437            return Err(EventDeclarationError::new(
438                name_field,
439                "must match [a-z0-9][a-z0-9_]{0,62}",
440            ));
441        }
442        let version = entry
443            .get("version")
444            .and_then(Value::as_u64)
445            .filter(|version| (1..=u32::MAX as u64).contains(version))
446            .ok_or_else(|| {
447                EventDeclarationError::new(
448                    format!("{field}.version"),
449                    "must be an integer between 1 and u32::MAX",
450                )
451            })?;
452        if !pairs.insert((name, version)) {
453            return Err(EventDeclarationError::new(
454                &field,
455                "duplicates an event (name, version) pair",
456            ));
457        }
458        let headers_field = format!("{field}.headers");
459        let headers = entry
460            .get("headers")
461            .and_then(Value::as_array)
462            .ok_or_else(|| EventDeclarationError::new(&headers_field, "must be a list"))?;
463        if headers.len() > 16 {
464            return Err(EventDeclarationError::new(
465                headers_field,
466                "must contain at most 16 headers",
467            ));
468        }
469        let mut names = HashSet::new();
470        for (header_index, header) in headers.iter().enumerate() {
471            let header_field = format!("{headers_field}[{header_index}]");
472            let header = header
473                .as_str()
474                .ok_or_else(|| EventDeclarationError::new(&header_field, "must be a string"))?;
475            if !(1..=32).contains(&header.len())
476                || !header.as_bytes()[0].is_ascii_lowercase()
477                || !header
478                    .bytes()
479                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
480            {
481                return Err(EventDeclarationError::new(
482                    header_field,
483                    "must match [a-z][a-z0-9_]{0,31}",
484                ));
485            }
486            if !names.insert(header) {
487                return Err(EventDeclarationError::new(
488                    header_field,
489                    "duplicates a header name",
490                ));
491            }
492        }
493        if let Some(summary) = entry.get("summary").filter(|value| !value.is_null()) {
494            let summary_field = format!("{field}.summary");
495            let summary = summary
496                .as_str()
497                .ok_or_else(|| EventDeclarationError::new(&summary_field, "must be a string"))?;
498            if summary.chars().count() > 200 || summary.chars().any(char::is_control) {
499                return Err(EventDeclarationError::new(
500                    summary_field,
501                    "must contain at most 200 characters and no control characters",
502                ));
503            }
504        }
505    }
506    Ok(())
507}
508
509/// A raw HELLO declaration error that can be reported before serde drops context.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct SelfSignalDeclarationError {
512    module_id: String,
513    entry_index: usize,
514    field: &'static str,
515}
516
517impl fmt::Display for SelfSignalDeclarationError {
518    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519        write!(
520            f,
521            "module_id '{}' self_signals[{}] is missing required field '{}'",
522            self.module_id.escape_debug(),
523            self.entry_index,
524            self.field
525        )
526    }
527}
528
529/// Reject raw HELLO self-signal declarations that omit `effect` or `anchored_to`.
530///
531/// Serde correctly rejects these omissions while decoding [`ModuleManifest`], but
532/// that decode does not retain the module id or list index needed for a useful
533/// daemon refusal. This preflight adds only that reporting context; it does not
534/// interpret a declaration's behavior.
535pub fn validate_hello_self_signal_declarations(
536    hello: &Value,
537) -> Result<(), SelfSignalDeclarationError> {
538    let Some(manifest) = hello.get("manifest").and_then(Value::as_object) else {
539        return Ok(());
540    };
541    let module_id = manifest
542        .get("module_id")
543        .and_then(Value::as_str)
544        .unwrap_or("<unknown>");
545    let Some(entries) = manifest.get("self_signals").and_then(Value::as_array) else {
546        return Ok(());
547    };
548
549    for (entry_index, entry) in entries.iter().enumerate() {
550        let Some(entry) = entry.as_object() else {
551            continue;
552        };
553        for field in ["effect", "anchored_to"] {
554            if !entry.contains_key(field) {
555                return Err(SelfSignalDeclarationError {
556                    module_id: module_id.to_string(),
557                    entry_index,
558                    field,
559                });
560            }
561        }
562    }
563    Ok(())
564}
565
566/// Static, versioned capabilities declared by a module.
567#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
568#[serde(deny_unknown_fields)]
569pub struct CapabilityDeclarations {
570    #[serde(default)]
571    pub provides: Vec<String>,
572    #[serde(default)]
573    pub requires: Vec<CapabilityRequirement>,
574    #[serde(default)]
575    pub must_never_reach: Vec<String>,
576}
577
578/// A declared periodic or event-driven behavior that shapes an external surface.
579#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
580pub struct SelfSignalDeclaration {
581    /// Stable identifier for this declared behavior, such as `codex_keepalive`.
582    pub name: String,
583    /// Signal classification. `Busy` participates in drain quiescence; the
584    /// remaining kinds are descriptive for operators and analysts.
585    ///
586    /// # A `Busy` GAUGE COUNTS WORK THE DRAIN CAN AFFECT, AND NOTHING ELSE
587    ///
588    /// The daemon waits for every declared `Busy` gauge to reach zero before
589    /// tearing a module down, up to the drain ceiling. That wait is only worth
590    /// anything for work whose RESULT WOULD BE DELIVERED if it finished during
591    /// the drain.
592    ///
593    /// The test: **if this request completed one millisecond before the
594    /// teardown, would anyone receive its answer?** If no, it does not belong
595    /// in a `Busy` gauge, and counting it makes the daemon wait out its whole
596    /// ceiling for something that cannot usefully complete.
597    ///
598    /// The case that names the rule (CEREB, 2026-09-19): cerebellum's
599    /// management requests can park for up to 300 s awaiting a HUMAN's consent
600    /// decision. Those were counted as in-flight, so a drain begun while a
601    /// prompt was open waited on a person until the 30 s ceiling expired --
602    /// and the pending answer dies with the process either way, so the wait
603    /// bought nothing. The fix splits the accounting: `executing` counts toward
604    /// `Busy`, `awaiting_consent` is reported as its own informational gauge.
605    ///
606    /// The same shape exists anywhere a request blocks on an external party the
607    /// restart invalidates -- a human, a peer seat, a vendor call whose reply
608    /// has nowhere to land. Those are legitimately IN FLIGHT and are not
609    /// legitimately `Busy`.
610    pub kind: SelfSignalKind,
611    /// Whether the signal only observes the surface or changes it.
612    pub effect: SelfSignalEffect,
613    /// The cadence, event, or health gauges that anchor this signal.
614    pub anchored_to: SignalAnchor,
615    /// The effective cadence in force at HELLO time.
616    ///
617    /// Use [`SignalCadence::Literal`] when a compile-time constant is the
618    /// effective value. Use [`SignalCadence::Derived`] when configuration or
619    /// another runtime input resolves the effective value, naming the source so
620    /// the declaration cannot silently drift from that resolution.
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub cadence: Option<SignalCadence>,
623    /// The external surface this behavior shapes, such as `provider-usage`.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub domain: Option<String>,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub note: Option<String>,
628}
629
630/// Class of a self-signal, tolerant of newer wire values.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum SelfSignalKind {
633    Keepalive,
634    /// Work that must settle before the daemon considers a module quiescent.
635    Busy,
636    Poller,
637    Cron,
638    Sweep,
639    Watchdog,
640    Heartbeat,
641    Other(String),
642}
643
644impl SelfSignalKind {
645    fn wire_name(&self) -> &str {
646        match self {
647            Self::Keepalive => "keepalive",
648            Self::Busy => "busy",
649            Self::Poller => "poller",
650            Self::Cron => "cron",
651            Self::Sweep => "sweep",
652            Self::Watchdog => "watchdog",
653            Self::Heartbeat => "heartbeat",
654            Self::Other(value) => value,
655        }
656    }
657}
658
659impl Serialize for SelfSignalKind {
660    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
661    where
662        S: serde::Serializer,
663    {
664        serializer.serialize_str(self.wire_name())
665    }
666}
667
668impl<'de> Deserialize<'de> for SelfSignalKind {
669    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
670    where
671        D: Deserializer<'de>,
672    {
673        let value = String::deserialize(deserializer)?;
674        Ok(match value.as_str() {
675            "keepalive" => Self::Keepalive,
676            "busy" => Self::Busy,
677            "poller" => Self::Poller,
678            "cron" => Self::Cron,
679            "sweep" => Self::Sweep,
680            "watchdog" => Self::Watchdog,
681            "heartbeat" => Self::Heartbeat,
682            _ => Self::Other(value),
683        })
684    }
685}
686
687/// The effect a self-signal has on the external surface it targets.
688#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
689#[serde(rename_all = "lowercase")]
690pub enum SelfSignalEffect {
691    Observe,
692    Mutate,
693}
694
695/// What anchors a self-signal to an interval, event, or health state.
696#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
697#[serde(rename_all = "snake_case")]
698pub enum SignalAnchor {
699    /// The behavior follows its own periodic signature, so analysts can find it
700    /// without an external event grid.
701    FixedInterval,
702    /// The behavior follows an external event boundary, which can make its shape
703    /// indistinguishable from the surface mechanism without this declaration.
704    Event { event: String },
705    /// Health metrics whose non-negative integer values are summed during drain.
706    HealthGauges { gauges: Vec<String> },
707}
708
709/// How a self-signal's effective cadence is declared.
710#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
711#[serde(rename_all = "snake_case")]
712pub enum SignalCadence {
713    Literal { interval_ms: u64 },
714    Derived { source: String },
715}
716
717/// Build facts a module DECLARES about its own binary at HELLO. The daemon
718/// overlays process-identity evidence it alone can attest; the two halves are
719/// served together via `supervisor.provenance` and never merged.
720///
721/// The canonical constructor form is a full 40-character lowercase hexadecimal
722/// `build_git_sha` and a full 64-character lowercase hexadecimal
723/// `build_lock_digest`; abbreviations are not conforming. The daemon's HELLO
724/// decoder intentionally remains lenient enough to relay older declarations,
725/// so this construction contract is enforced by [`build_provenance`] rather
726/// than by wire deserialization.
727///
728/// Honesty contract for constructors (ruled with the first adopters):
729/// - Every field is a VERIFIED-AT-BUILD claim. No field is required: a module
730///   may declare any subset, and omitting an inapplicable field is the honest
731///   choice rather than inventing a value to fill it. Populate `build_git_sha`
732///   only from a value injected by the build/release pipeline (`CK_BUILD_REV`
733///   via `option_env!` guarded by the packaging path, or build.rs equivalent)
734///   — never from ambient env at an arbitrary consumer compile, which mints a
735///   provenance claim from an accident of whoever ran cargo. A builder that
736///   can determine whether the tree was clean may declare the sha regardless
737///   of whether a release pipeline exists.
738/// - Dirty or unstamped builds declare `None` for the affected fields. A
739///   populated field stops the reader asking; absent-and-honest beats
740///   present-and-best-effort. Absence is reported at two levels with two
741///   distinct words: a module that declared no provenance block at all reads
742///   `unverifiable`, while an omitted field inside a declared block is
743///   dropped from the wire and reads `unavailable`. So omitting a field never
744///   costs a module its `Reported` status -- declaration is decided by
745///   whether the manifest carried a block, not by which fields it filled.
746/// - Dirty-tree stamps are not canonical `build_git_sha` values. A pipeline
747///   that emits `-dirty` must omit the affected field rather than pass that
748///   stamp to the canonical constructor. Stricter is better: cerebellum's
749///   build.rs reports the commit ONLY when the tree was clean, on the argument
750///   that dirty bytes match no commit and a precise-looking wrong answer beats
751///   absence at being believed.
752/// - Two silent-when-wrong checks for any build-rev embedder (CEREB): does
753///   the builder know whether the tree was clean, and can its no-git sentinel
754///   (source-tarball builds) escape into a field parsed as a sha? Sentinels
755///   render as absence, never as a value.
756/// - Fill fields FROM THE BUILD only: reading Cargo.lock or the wire crate
757///   version inside the manifest constructor describes the source tree
758///   sitting beside the running binary, not the binary — the exact claim
759///   this struct exists to avoid.
760/// - Declare what you KNOW, not blanket-None (WERNI): `store_schema_version`
761///   needs no pipeline — any module with a migration list can state its
762///   newest migration as fact, and a daemon comparing it against the store's
763///   actual version sees a stale-binary mismatch directly. Blanket `None`
764///   where a field is knowable wastes the field; blanket-fill where it is
765///   not mints a lie. Absence also beats sentinel values (CKCRED): omit the
766///   FIELD when BUILD_REV reads a builder sentinel ("unknown", "unavailable",
767///   "none", any casing) — publishing the sentinel string as a fact is a
768///   well-formed lie shape validation cannot catch. Field omission, not block
769///   omission, is the target shape for SDK modules: `wire_crate_version` is a
770///   compile-time constant of the linked crate, so a module using the SDK
771///   always has at least one honest fact and `build_provenance` reflects that
772///   by never returning an absent block. (Block absence remains meaningful on
773///   the wire — it reads `unverifiable`, the module made no claim — but it is
774///   the shape for non-adopters and proxied manifests, not a target for
775///   declarers; see #78.) The hazard in one sentence, for every referent and
776///   sentinel case alike: A PRESENT, WELL-FORMED FIELD STOPS THE READER
777///   ASKING — a value from the wrong domain and a sentinel from the wrong
778///   vocabulary are indistinguishable from a correct value to every check
779///   that inspects shape rather than meaning.
780/// - PROXIED MANIFESTS STAY None PERMANENTLY (CALLO): a process that
781///   forwards another machine's manifest cannot observe that build, and a
782///   forwarded provenance claim is indistinguishable on the wire from a
783///   verified one — filling it launders an unverifiable assertion. Same
784///   reasoning as pinning a re-exported module's trust_tier to Untrusted.
785///   Record that at the construction site: injection-wiring sweeps grep for
786///   `provenance:` and the obvious action at a re-export site is the wrong
787///   one.
788///
789/// `#[non_exhaustive]`: a new provenance fact must not break the modules that
790/// build this block, so construct it with [`ManifestProvenance::new`] and the
791/// `with_*` setters (or the `build_provenance*` helpers), never a struct
792/// literal.
793#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)]
794#[non_exhaustive]
795pub struct ManifestProvenance {
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub build_git_sha: Option<String>,
798    /// Why `build_git_sha` is unavailable. This is absent when the commit is
799    /// declared, and remains open so future causes do not make readers reject
800    /// the enclosing provenance declaration.
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub build_lock_digest: Option<String>,
805    /// REFERENT: the `subc-protocol` crate version linked into this binary
806    /// (`subc_protocol::SUBC_PROTOCOL_CRATE_VERSION`) — the fleet's shared
807    /// wire vocabulary, one numbering space for every module. Never a
808    /// module's own envelope/payload crate version: that is real information
809    /// in a different numbering space, and here it scores as a confident
810    /// wrong answer at any census gate. (QTA's rule, learned live: a field
811    /// whose entire content is a referent cannot be documented by its
812    /// constraints — so the referent is stated here, where readers look.)
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub wire_crate_version: Option<String>,
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub store_schema_version: Option<String>,
817    /// Where the running module read its launch nonce from: `fd` (the pipe
818    /// the daemon hands over as descriptor 3) or `env` (the environment
819    /// variable kept while modules move to the pipe). Absent means the module
820    /// did not say. This is a fact about the running process, not the build.
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub launch_nonce_source: Option<LaunchNonceSource>,
823}
824
825/// Where a module read its launch nonce from, as reported in
826/// [`ManifestProvenance::launch_nonce_source`].
827///
828/// An open string enum, like [`BuildGitShaAbsenceReason`]: a value this crate
829/// does not know decodes as `ForwardCompatibleUnknown` instead of making the
830/// whole provenance block, and with it the module's HELLO, fail to decode.
831#[derive(Debug, Clone, PartialEq, Eq)]
832#[non_exhaustive]
833pub enum LaunchNonceSource {
834    /// Read from the inherited descriptor the daemon passes.
835    Fd,
836    /// Read from the `SUBC_LAUNCH_NONCE` environment variable.
837    Env,
838    ForwardCompatibleUnknown(String),
839}
840
841impl LaunchNonceSource {
842    /// The value as it appears on the wire.
843    pub fn wire_name(&self) -> &str {
844        match self {
845            Self::Fd => "fd",
846            Self::Env => "env",
847            Self::ForwardCompatibleUnknown(value) => value,
848        }
849    }
850
851    /// The source a wire value names; unknown values are kept as they are.
852    pub fn from_wire_name(value: &str) -> Self {
853        match value {
854            "fd" => Self::Fd,
855            "env" => Self::Env,
856            _ => Self::ForwardCompatibleUnknown(value.to_string()),
857        }
858    }
859}
860
861impl Serialize for LaunchNonceSource {
862    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
863    where
864        S: serde::Serializer,
865    {
866        serializer.serialize_str(self.wire_name())
867    }
868}
869
870impl<'de> Deserialize<'de> for LaunchNonceSource {
871    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
872    where
873        D: serde::Deserializer<'de>,
874    {
875        let value = String::deserialize(deserializer)?;
876        Ok(Self::from_wire_name(&value))
877    }
878}
879
880/// A build pipeline's reason for omitting `build_git_sha`.
881///
882/// This is an open string enum: consumers preserve a future reason instead of
883/// rejecting the enclosing provenance declaration.
884#[derive(Debug, Clone, PartialEq, Eq)]
885pub enum BuildGitShaAbsenceReason {
886    DeclinedDirty,
887    NeverDerived,
888    NoGitDir,
889    ForwardCompatibleUnknown(String),
890}
891
892impl BuildGitShaAbsenceReason {
893    fn wire_name(&self) -> &str {
894        match self {
895            Self::DeclinedDirty => "declined_dirty",
896            Self::NeverDerived => "never_derived",
897            Self::NoGitDir => "no_git_dir",
898            Self::ForwardCompatibleUnknown(value) => value,
899        }
900    }
901}
902
903impl Serialize for BuildGitShaAbsenceReason {
904    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
905    where
906        S: serde::Serializer,
907    {
908        serializer.serialize_str(self.wire_name())
909    }
910}
911
912impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
913    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
914    where
915        D: serde::Deserializer<'de>,
916    {
917        let value = String::deserialize(deserializer)?;
918        Ok(match value.as_str() {
919            "declined_dirty" => Self::DeclinedDirty,
920            "never_derived" => Self::NeverDerived,
921            "no_git_dir" => Self::NoGitDir,
922            _ => Self::ForwardCompatibleUnknown(value),
923        })
924    }
925}
926
927/// The observable state of a git worktree when a build pipeline found a revision.
928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
929pub enum GitTreeState {
930    Clean,
931    Dirty,
932}
933
934/// How the build pipeline obtained (or did not obtain) git revision data.
935///
936/// `NoGitDir` and `NeverDerived` carry no revision, so callers cannot attach
937/// those absence causes to an otherwise attested commit through this API.
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub enum BuildGitShaSource<'a> {
940    Git {
941        revision: &'a str,
942        tree_state: GitTreeState,
943    },
944    NeverDerived,
945    NoGitDir,
946}
947
948/// Return a commit only when its source tree was clean at build time.
949///
950/// The rule is pure so stampers can exercise both branches without rebuilding.
951pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
952    match tree_state {
953        GitTreeState::Clean => Some(revision),
954        GitTreeState::Dirty => None,
955    }
956}
957
958const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
959const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
960const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
961
962#[derive(Deserialize)]
963struct ManifestProvenanceWire {
964    #[serde(default)]
965    build_git_sha: Option<String>,
966    #[serde(default)]
967    build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
968    #[serde(default)]
969    build_lock_digest: Option<String>,
970    #[serde(default)]
971    wire_crate_version: Option<String>,
972    #[serde(default)]
973    store_schema_version: Option<String>,
974    #[serde(default)]
975    launch_nonce_source: Option<LaunchNonceSource>,
976}
977
978impl<'de> Deserialize<'de> for ManifestProvenance {
979    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
980    where
981        D: Deserializer<'de>,
982    {
983        let wire = ManifestProvenanceWire::deserialize(deserializer)?;
984        let provenance = Self {
985            build_git_sha: wire.build_git_sha,
986            build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
987            build_lock_digest: wire.build_lock_digest,
988            wire_crate_version: wire.wire_crate_version,
989            store_schema_version: wire.store_schema_version,
990            launch_nonce_source: wire.launch_nonce_source,
991        };
992        provenance.validate().map_err(D::Error::custom)?;
993        Ok(provenance)
994    }
995}
996
997/// A declared build fact did not use its canonical form.
998#[derive(Debug, Clone, PartialEq, Eq)]
999pub struct ProvenanceFormError {
1000    field: &'static str,
1001    length: usize,
1002    canonical_form: &'static str,
1003}
1004
1005impl ProvenanceFormError {
1006    fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
1007        Self {
1008            field,
1009            length,
1010            canonical_form,
1011        }
1012    }
1013
1014    /// The provenance field whose value was not canonical.
1015    pub fn field(&self) -> &str {
1016        self.field
1017    }
1018
1019    /// The offending value's length in bytes.
1020    pub fn length(&self) -> usize {
1021        self.length
1022    }
1023
1024    /// The canonical form required for this field.
1025    pub fn canonical_form(&self) -> &str {
1026        self.canonical_form
1027    }
1028}
1029
1030impl fmt::Display for ProvenanceFormError {
1031    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1032        write!(
1033            f,
1034            "invalid manifest provenance form: field {} has length {}; canonical form is {}",
1035            self.field, self.length, self.canonical_form
1036        )
1037    }
1038}
1039
1040impl std::error::Error for ProvenanceFormError {}
1041
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct ManifestProvenanceError {
1044    field: String,
1045    value: String,
1046    reason: &'static str,
1047}
1048
1049impl ManifestProvenanceError {
1050    fn new(field: &str, value: &str, reason: &'static str) -> Self {
1051        Self {
1052            field: field.to_string(),
1053            value: safe_error_value(value),
1054            reason,
1055        }
1056    }
1057
1058    pub fn field(&self) -> &str {
1059        &self.field
1060    }
1061}
1062
1063impl fmt::Display for ManifestProvenanceError {
1064    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1065        write!(
1066            f,
1067            "invalid manifest provenance: field {} has {} (value {:?})",
1068            self.field, self.reason, self.value
1069        )
1070    }
1071}
1072
1073impl std::error::Error for ManifestProvenanceError {}
1074
1075impl ManifestProvenance {
1076    /// A block declaring nothing; add facts with the `with_*` setters.
1077    pub fn new() -> Self {
1078        Self::default()
1079    }
1080
1081    pub fn with_build_git_sha(mut self, value: Option<String>) -> Self {
1082        self.build_git_sha = value;
1083        self
1084    }
1085
1086    pub fn with_build_git_sha_absence_reason(
1087        mut self,
1088        value: Option<BuildGitShaAbsenceReason>,
1089    ) -> Self {
1090        self.build_git_sha_absence_reason = value;
1091        self
1092    }
1093
1094    pub fn with_build_lock_digest(mut self, value: Option<String>) -> Self {
1095        self.build_lock_digest = value;
1096        self
1097    }
1098
1099    pub fn with_wire_crate_version(mut self, value: Option<String>) -> Self {
1100        self.wire_crate_version = value;
1101        self
1102    }
1103
1104    pub fn with_store_schema_version(mut self, value: Option<String>) -> Self {
1105        self.store_schema_version = value;
1106        self
1107    }
1108
1109    /// Report where this process read its launch nonce from. A module takes
1110    /// it from its nonce accessor's cached source, so whoever reads the fleet's
1111    /// provenance (`ck provenance`) can tell a module reading the pipe from
1112    /// one still reading the environment variable.
1113    pub fn with_launch_nonce_source(mut self, value: Option<LaunchNonceSource>) -> Self {
1114        self.launch_nonce_source = value;
1115        self
1116    }
1117
1118    pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
1119        if let (Some(_), Some(reason)) = (
1120            self.build_git_sha.as_ref(),
1121            self.build_git_sha_absence_reason.as_ref(),
1122        ) {
1123            return Err(ManifestProvenanceError::new(
1124                "build_git_sha_absence_reason",
1125                reason.wire_name(),
1126                "must be omitted when build_git_sha is present",
1127            ));
1128        }
1129        for (field, value) in [
1130            ("build_git_sha", self.build_git_sha.as_deref()),
1131            (
1132                "build_git_sha_absence_reason",
1133                self.build_git_sha_absence_reason
1134                    .as_ref()
1135                    .map(|reason| reason.wire_name()),
1136            ),
1137            ("build_lock_digest", self.build_lock_digest.as_deref()),
1138            ("wire_crate_version", self.wire_crate_version.as_deref()),
1139            ("store_schema_version", self.store_schema_version.as_deref()),
1140            (
1141                "launch_nonce_source",
1142                self.launch_nonce_source
1143                    .as_ref()
1144                    .map(|source| source.wire_name()),
1145            ),
1146        ] {
1147            let Some(value) = value else { continue };
1148            if value.is_empty() {
1149                return Err(ManifestProvenanceError::new(
1150                    field,
1151                    value,
1152                    "must not be empty",
1153                ));
1154            }
1155            // HELLO decoding checks only wire safety here. Canonical build forms
1156            // belong to build_provenance; the daemon is a non-adjudicating relayer
1157            // and must continue accepting legacy declarations such as 12-hex
1158            // module revisions rather than breaking a fleet on daemon upgrade.
1159            if value.len() > MAX_PROVENANCE_VALUE_BYTES {
1160                return Err(ManifestProvenanceError::new(
1161                    field,
1162                    value,
1163                    "exceeds the 128-byte maximum",
1164                ));
1165            }
1166            if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
1167                return Err(ManifestProvenanceError::new(
1168                    field,
1169                    value,
1170                    "contains non-printable ASCII",
1171                ));
1172            }
1173        }
1174        Ok(())
1175    }
1176}
1177
1178/// Build [`ManifestProvenance`] from legacy raw build facts.
1179///
1180/// Callers of this compatibility path did not supply the tree state that
1181/// explains an omitted SHA. It emits no absence reason not because the absence
1182/// has no cause, but because guessing one without that state would fabricate
1183/// the fact this API exists to report honestly.
1184///
1185/// ```
1186/// use subc_protocol::manifest::build_provenance;
1187///
1188/// let provenance = build_provenance(option_env!("CK_BUILD_REV"), None, None)
1189///     .expect("legacy build facts remain supported");
1190/// assert!(provenance.build_git_sha_absence_reason.is_none());
1191/// ```
1192///
1193/// Sentinel and empty values become field omission before canonical form
1194/// validation, preserving the established three-argument wire behavior.
1195pub fn build_provenance(
1196    build_git_sha: Option<&str>,
1197    build_lock_digest: Option<&str>,
1198    store_schema_version: Option<&str>,
1199) -> Result<ManifestProvenance, ProvenanceFormError> {
1200    let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
1201    build_provenance_with_build_git_sha(
1202        build_git_sha,
1203        None,
1204        build_lock_digest,
1205        store_schema_version,
1206    )
1207}
1208
1209/// Build a [`ManifestProvenance`] from source-state-aware build facts.
1210///
1211/// The source state makes the SHA absence cause attestable: `Dirty` declines
1212/// the commit, while `NeverDerived` and `NoGitDir` name distinct source paths.
1213/// A `build_git_sha` must be exactly 40 lowercase hexadecimal characters and a
1214/// `build_lock_digest` exactly 64 lowercase hexadecimal characters. Abbreviations
1215/// are not conforming; a real value in the wrong form returns a
1216/// [`ProvenanceFormError`] instead of being discarded as if it were absent.
1217/// Sentinel values are filtered before form validation, so they remain honest
1218/// omission rather than becoming form errors.
1219///
1220/// OWNERSHIP RULE: a helper that constructs a wire type lives in the crate
1221/// that owns the type. This helper constructs `ManifestProvenance`, so it
1222/// lives here in subc-protocol (not in subc-client-rs) — transport-direct
1223/// modules that never link the client SDK can still build honest provenance.
1224pub fn build_provenance_from_source(
1225    build_git_sha_source: BuildGitShaSource<'_>,
1226    build_lock_digest: Option<&str>,
1227    store_schema_version: Option<&str>,
1228) -> Result<ManifestProvenance, ProvenanceFormError> {
1229    let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
1230        BuildGitShaSource::Git {
1231            revision,
1232            tree_state,
1233        } => match attestable_commit(revision, tree_state) {
1234            Some(revision) => (Some(revision), None),
1235            None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
1236        },
1237        BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
1238        BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
1239    };
1240    let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
1241    if build_git_sha.is_none() {
1242        build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
1243    }
1244    build_provenance_with_build_git_sha(
1245        build_git_sha,
1246        build_git_sha_absence_reason,
1247        build_lock_digest,
1248        store_schema_version,
1249    )
1250}
1251
1252fn normalize_and_validate_build_git_sha(
1253    build_git_sha: Option<&str>,
1254) -> Result<Option<String>, ProvenanceFormError> {
1255    let build_git_sha = normalize_provenance_fact(build_git_sha);
1256    validate_provenance_form(
1257        "build_git_sha",
1258        build_git_sha.as_deref(),
1259        BUILD_GIT_SHA_CANONICAL_FORM,
1260        40,
1261    )?;
1262    Ok(build_git_sha)
1263}
1264
1265fn build_provenance_with_build_git_sha(
1266    build_git_sha: Option<String>,
1267    build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
1268    build_lock_digest: Option<&str>,
1269    store_schema_version: Option<&str>,
1270) -> Result<ManifestProvenance, ProvenanceFormError> {
1271    let build_lock_digest = normalize_provenance_fact(build_lock_digest);
1272    validate_provenance_form(
1273        "build_lock_digest",
1274        build_lock_digest.as_deref(),
1275        BUILD_LOCK_DIGEST_CANONICAL_FORM,
1276        64,
1277    )?;
1278
1279    Ok(ManifestProvenance {
1280        build_git_sha,
1281        build_git_sha_absence_reason,
1282        build_lock_digest,
1283        wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1284        store_schema_version: normalize_provenance_fact(store_schema_version),
1285        launch_nonce_source: None,
1286    })
1287}
1288
1289fn validate_provenance_form(
1290    field: &'static str,
1291    value: Option<&str>,
1292    canonical_form: &'static str,
1293    expected_length: usize,
1294) -> Result<(), ProvenanceFormError> {
1295    let Some(value) = value else { return Ok(()) };
1296    if value.len() != expected_length
1297        || !value
1298            .bytes()
1299            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1300    {
1301        return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
1302    }
1303    Ok(())
1304}
1305
1306/// Sentinel strings that build tooling emits where it means "no value": shell
1307/// fallbacks and Makefile defaults produce `unknown`, wire vocabulary uses
1308/// `unavailable`, and `git describe` failures surface as `none`. Publishing
1309/// any of them as a fact is the well-formed-lie shape the provenance contract
1310/// warns against — a present, well-formed field stops the reader asking — so
1311/// the helper maps them all to field omission. Matched case-insensitively
1312/// because `UNKNOWN`/`Unknown` are equally common from shell fallbacks.
1313pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
1314
1315fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
1316    let value = value?.trim();
1317    if value.is_empty() {
1318        return None;
1319    }
1320    let lowered = value.to_ascii_lowercase();
1321    if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
1322        return None;
1323    }
1324    Some(value.to_string())
1325}
1326
1327/// One capability a module consumes and whether its absence is tolerated.
1328#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1329#[serde(deny_unknown_fields)]
1330pub struct CapabilityRequirement {
1331    pub capability: String,
1332    pub need: CapabilityNeed,
1333}
1334
1335/// Closed capability requirement strength vocabulary.
1336#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1337#[serde(rename_all = "snake_case")]
1338pub enum CapabilityNeed {
1339    Required,
1340    Optional,
1341}
1342
1343/// A safe-to-report capability-schema validation failure.
1344#[derive(Debug, Clone, PartialEq, Eq)]
1345pub struct CapabilityGrammarError {
1346    field: String,
1347    value: String,
1348}
1349
1350impl CapabilityGrammarError {
1351    fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1352        Self {
1353            field: field.into(),
1354            value: safe_error_value(value.as_ref()),
1355        }
1356    }
1357
1358    /// The precise malformed field path.
1359    pub fn field(&self) -> &str {
1360        &self.field
1361    }
1362
1363    /// The offending value, redacted when it resembles a credential.
1364    pub fn value(&self) -> &str {
1365        &self.value
1366    }
1367}
1368
1369impl fmt::Display for CapabilityGrammarError {
1370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1371        write!(
1372            f,
1373            "invalid capability grammar: field {} has offending value {:?}",
1374            self.field, self.value
1375        )
1376    }
1377}
1378
1379impl std::error::Error for CapabilityGrammarError {}
1380
1381impl ModuleManifest {
1382    /// Validate the typed capability block after serde has decoded it.
1383    pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1384        let Some(capabilities) = &self.capabilities else {
1385            return Ok(());
1386        };
1387
1388        validate_capability_list("capabilities.provides", &capabilities.provides)?;
1389        validate_requires(&capabilities.requires)?;
1390        validate_capability_list(
1391            "capabilities.must_never_reach",
1392            &capabilities.must_never_reach,
1393        )
1394    }
1395}
1396
1397/// Validate capability grammar in a standalone manifest JSON value.
1398///
1399/// The raw-value form lets HELLO distinguish schema failures from malformed JSON,
1400/// including an unknown `need` that cannot be represented by [`CapabilityNeed`].
1401pub fn validate_manifest_capability_grammar(
1402    manifest: &Value,
1403) -> Result<(), CapabilityGrammarError> {
1404    let Some(object) = manifest.as_object() else {
1405        return Ok(());
1406    };
1407
1408    validate_capabilities_value(object.get("capabilities"))?;
1409    validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1410}
1411
1412/// Validate capability grammar in a raw HELLO body.
1413///
1414/// `runtime_computed` is a top-level sibling in --manifest output. HELLO keeps
1415/// accepting that sibling only so an attempted dynamic capability declaration is
1416/// refused explicitly instead of being silently ignored by serde.
1417pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1418    let Some(object) = hello.as_object() else {
1419        return Ok(());
1420    };
1421    if let Some(manifest) = object.get("manifest") {
1422        validate_manifest_capability_grammar(manifest)?;
1423    }
1424    validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1425}
1426
1427/// Return whether `identifier` has the exact `<name>/v<N>` capability spelling.
1428pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1429    if identifier.chars().any(char::is_whitespace) {
1430        return false;
1431    }
1432    let Some((name, version)) = identifier.split_once("/v") else {
1433        return false;
1434    };
1435    if name.is_empty() || name.len() > 64 || version.is_empty() {
1436        return false;
1437    }
1438
1439    let name_bytes = name.as_bytes();
1440    if !name_bytes[0].is_ascii_lowercase()
1441        || (name.len() > 1
1442            && !name_bytes[name.len() - 1].is_ascii_lowercase()
1443            && !name_bytes[name.len() - 1].is_ascii_digit())
1444        || name_bytes.windows(2).any(|pair| pair == b"--")
1445    {
1446        return false;
1447    }
1448    if !name_bytes
1449        .iter()
1450        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1451    {
1452        return false;
1453    }
1454
1455    if version.len() > 1 && version.starts_with('0')
1456        || !version.bytes().all(|byte| byte.is_ascii_digit())
1457    {
1458        return false;
1459    }
1460    matches!(
1461        version.parse::<u64>(),
1462        Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1463    )
1464}
1465
1466fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1467    let Some(value) = value else {
1468        return Ok(());
1469    };
1470    let Some(object) = value.as_object() else {
1471        return Err(CapabilityGrammarError::new(
1472            "capabilities",
1473            value_description(value),
1474        ));
1475    };
1476
1477    for (key, value) in object {
1478        if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1479            return Err(CapabilityGrammarError::new(
1480                field_child("capabilities", key),
1481                value_description(value),
1482            ));
1483        }
1484    }
1485
1486    validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1487    validate_requires_value(object.get("requires"))?;
1488    validate_capability_list_value(
1489        "capabilities.must_never_reach",
1490        object.get("must_never_reach"),
1491    )
1492}
1493
1494fn validate_capability_list_value(
1495    field: &str,
1496    value: Option<&Value>,
1497) -> Result<(), CapabilityGrammarError> {
1498    let Some(value) = value else {
1499        return Ok(());
1500    };
1501    let Some(values) = value.as_array() else {
1502        return Err(CapabilityGrammarError::new(field, value_description(value)));
1503    };
1504
1505    let mut seen = HashSet::new();
1506    for (index, value) in values.iter().enumerate() {
1507        let field = format!("{field}[{index}]");
1508        let Some(identifier) = value.as_str() else {
1509            return Err(CapabilityGrammarError::new(field, value_description(value)));
1510        };
1511        validate_capability_identifier(&field, identifier)?;
1512        if !seen.insert(identifier) {
1513            return Err(CapabilityGrammarError::new(field, identifier));
1514        }
1515    }
1516    Ok(())
1517}
1518
1519fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1520    let Some(value) = value else {
1521        return Ok(());
1522    };
1523    let Some(values) = value.as_array() else {
1524        return Err(CapabilityGrammarError::new(
1525            "capabilities.requires",
1526            value_description(value),
1527        ));
1528    };
1529
1530    let mut seen = HashSet::new();
1531    for (index, value) in values.iter().enumerate() {
1532        let entry_field = format!("capabilities.requires[{index}]");
1533        let Some(object) = value.as_object() else {
1534            return Err(CapabilityGrammarError::new(
1535                entry_field,
1536                value_description(value),
1537            ));
1538        };
1539        for (key, value) in object {
1540            if !matches!(key.as_str(), "capability" | "need") {
1541                return Err(CapabilityGrammarError::new(
1542                    field_child(&entry_field, key),
1543                    value_description(value),
1544                ));
1545            }
1546        }
1547        let capability_field = format!("{entry_field}.capability");
1548        let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1549            return Err(CapabilityGrammarError::new(
1550                capability_field,
1551                object
1552                    .get("capability")
1553                    .map_or("<missing>".to_string(), value_description),
1554            ));
1555        };
1556        validate_capability_identifier(&capability_field, capability)?;
1557
1558        let need_field = format!("{entry_field}.need");
1559        let Some(need) = object.get("need").and_then(Value::as_str) else {
1560            return Err(CapabilityGrammarError::new(
1561                need_field,
1562                object
1563                    .get("need")
1564                    .map_or("<missing>".to_string(), value_description),
1565            ));
1566        };
1567        if !matches!(need, "required" | "optional") {
1568            return Err(CapabilityGrammarError::new(need_field, need));
1569        }
1570        if !seen.insert(capability) {
1571            return Err(CapabilityGrammarError::new(entry_field, capability));
1572        }
1573    }
1574    Ok(())
1575}
1576
1577fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1578    let mut seen = HashSet::new();
1579    for (index, identifier) in values.iter().enumerate() {
1580        let field = format!("{field}[{index}]");
1581        validate_capability_identifier(&field, identifier)?;
1582        if !seen.insert(identifier) {
1583            return Err(CapabilityGrammarError::new(field, identifier));
1584        }
1585    }
1586    Ok(())
1587}
1588
1589fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1590    let mut seen = HashSet::new();
1591    for (index, requirement) in values.iter().enumerate() {
1592        let field = format!("capabilities.requires[{index}].capability");
1593        validate_capability_identifier(&field, &requirement.capability)?;
1594        if !seen.insert(&requirement.capability) {
1595            return Err(CapabilityGrammarError::new(
1596                format!("capabilities.requires[{index}]"),
1597                &requirement.capability,
1598            ));
1599        }
1600    }
1601    Ok(())
1602}
1603
1604fn validate_capability_identifier(
1605    field: &str,
1606    identifier: &str,
1607) -> Result<(), CapabilityGrammarError> {
1608    if is_valid_capability_identifier(identifier) {
1609        Ok(())
1610    } else {
1611        Err(CapabilityGrammarError::new(field, identifier))
1612    }
1613}
1614
1615fn validate_runtime_computed(
1616    value: Option<&Value>,
1617    field: &str,
1618) -> Result<(), CapabilityGrammarError> {
1619    let Some(value) = value else {
1620        return Ok(());
1621    };
1622    let Some(pointers) = value.as_array() else {
1623        return Err(CapabilityGrammarError::new(field, value_description(value)));
1624    };
1625
1626    for (index, pointer) in pointers.iter().enumerate() {
1627        let field = format!("{field}[{index}]");
1628        let Some(pointer) = pointer.as_str() else {
1629            return Err(CapabilityGrammarError::new(
1630                field,
1631                value_description(pointer),
1632            ));
1633        };
1634        let Some(tokens) = parse_json_pointer(pointer) else {
1635            return Err(CapabilityGrammarError::new(field, pointer));
1636        };
1637        if tokens.first().is_some_and(|token| token == "capabilities") {
1638            return Err(CapabilityGrammarError::new(field, pointer));
1639        }
1640    }
1641    Ok(())
1642}
1643
1644fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1645    if pointer.is_empty() {
1646        return Some(Vec::new());
1647    }
1648    let raw_tokens = pointer.strip_prefix('/')?;
1649    raw_tokens
1650        .split('/')
1651        .map(unescape_json_pointer_token)
1652        .collect()
1653}
1654
1655fn unescape_json_pointer_token(token: &str) -> Option<String> {
1656    let mut output = String::with_capacity(token.len());
1657    let mut characters = token.chars();
1658    while let Some(character) = characters.next() {
1659        if character != '~' {
1660            output.push(character);
1661            continue;
1662        }
1663        match characters.next()? {
1664            '0' => output.push('~'),
1665            '1' => output.push('/'),
1666            _ => return None,
1667        }
1668    }
1669    Some(output)
1670}
1671
1672fn field_child(parent: &str, child: &str) -> String {
1673    let child = safe_error_value(child);
1674    format!("{parent}.{child}")
1675}
1676
1677fn value_description(value: &Value) -> String {
1678    match value {
1679        Value::String(value) => safe_error_value(value),
1680        Value::Null => "null".to_string(),
1681        Value::Bool(value) => value.to_string(),
1682        Value::Number(value) => value.to_string(),
1683        Value::Array(_) => "<array>".to_string(),
1684        Value::Object(_) => "<object>".to_string(),
1685    }
1686}
1687
1688fn safe_error_value(value: &str) -> String {
1689    let lower = value.to_ascii_lowercase();
1690    if ["secret", "password", "api_key"]
1691        .iter()
1692        .any(|marker| lower.contains(marker))
1693        || lower.starts_with("sk-")
1694        || lower.starts_with("akia")
1695        || lower.starts_with("bearer ")
1696        || lower.starts_with("token=")
1697        || lower.starts_with("credential=")
1698    {
1699        "<redacted>".to_string()
1700    } else {
1701        value.to_string()
1702    }
1703}
1704
1705/// How this module was sourced, as declared by the module itself.
1706///
1707/// Not read on any daemon routing or admission path; relayed verbatim. A
1708/// module declares it because it describes the module, not because the
1709/// daemon consumes it, and leaves it absent rather than inventing a value.
1710#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1711#[serde(rename_all = "snake_case")]
1712pub enum TrustTier {
1713    FirstParty,
1714    Reviewed,
1715    Untrusted,
1716}
1717
1718/// Provider capabilities exposed by a module.
1719///
1720/// The role set is closed for protocol v1; unknown role tags fail serde decode.
1721#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1722#[serde(tag = "role", rename_all = "snake_case")]
1723pub enum ProviderRole {
1724    ToolProvider {
1725        tools: Vec<Tool>,
1726        /// Which `BindIdentity` keys PARTITION this provider's state or
1727        /// answers: a module whose reply to a call depends on the caller's
1728        /// project declares `Project`; one that threads per session declares
1729        /// `Session`; one that answers identically to every caller declares
1730        /// `[]`. It states what the module does with the keys it is handed,
1731        /// not which keys it will accept — every bind carries all of them.
1732        /// Not read on any daemon path; relayed verbatim for consumers.
1733        identity_scope: Vec<IdentityScope>,
1734        concurrency: Concurrency,
1735        emits_push: bool,
1736        sub_supervises: bool,
1737    },
1738    PipelineStage {
1739        stage: PipelineStageKind,
1740        applies_to: PipelineAppliesTo,
1741        interface: String,
1742        declares_frozen_floor: bool,
1743        needs_signals: Vec<String>,
1744        conformance_class: String,
1745    },
1746    ManagementSurface {
1747        operations: Vec<ManagementOperation>,
1748        config_schema: Value,
1749        observability: Vec<ObservabilitySurface>,
1750        /// Same meaning as on `ToolProvider`: the keys that partition this
1751        /// surface's state or answers; `[]` for a surface that serves the
1752        /// same answer to every caller.
1753        identity_scope: Vec<IdentityScope>,
1754        #[serde(default)]
1755        concurrency: Concurrency,
1756    },
1757    InternalService {
1758        service_id: String,
1759        transport: InternalTransport,
1760        agent_facing: bool,
1761        operations: Vec<String>,
1762    },
1763}
1764
1765/// How a tool's side effects are fenced for durable at-most-once handling.
1766///
1767/// Classified on a tool's externally-observable effects, never inferred from
1768/// the module's concurrency lane:
1769/// - `Pure`: no observable side effect (reads, searches, cache warming) — safe
1770///   to re-run after an indeterminate outcome.
1771/// - `Mutating`: a fenceable external side effect such as a file write — a
1772///   re-run risks a duplicate effect, so an indeterminate outcome must not
1773///   auto-retry.
1774/// - `Unfenceable`: a side effect that cannot be fenced or safely replayed,
1775///   such as running a shell command — never auto-re-run on an indeterminate
1776///   outcome.
1777#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1778#[serde(rename_all = "snake_case")]
1779pub enum ExecutionMode {
1780    Pure,
1781    Mutating,
1782    Unfenceable,
1783}
1784
1785/// Tool-plane capability exposed by a `tool_provider`.
1786#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1787pub struct Tool {
1788    pub name: String,
1789    #[serde(default, skip_serializing_if = "Option::is_none")]
1790    pub description: Option<String>,
1791    /// How the tool's side effects are fenced for durable at-most-once handling.
1792    /// Observability + durability metadata only; subc's thin core never acts on
1793    /// this for routing, scheduling, or concurrency — the module's declared
1794    /// [`Concurrency`] contract governs delivery.
1795    pub execution_mode: ExecutionMode,
1796    pub schema: Value,
1797}
1798
1799/// How subc may deliver concurrent in-flight calls to the provider.
1800///
1801/// subc records and forwards these semantics unchanged; the dispatcher that
1802/// enforces them lives in subc-core, kept separate from this frozen manifest
1803/// contract.
1804#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1805#[serde(rename_all = "snake_case")]
1806pub enum Concurrency {
1807    /// One in-flight call at a time with strict submission and response order.
1808    Serial,
1809    /// Concurrent in-flight calls may span channels, while subc preserves FIFO
1810    /// submission within each channel; the module schedules internally.
1811    ModuleManaged,
1812    /// Fully parallel delivery with no ordering guarantee across or within
1813    /// channels.
1814    StatelessParallel,
1815}
1816
1817#[allow(clippy::derivable_impls)]
1818// The default is PINNED BY HISTORY, not chosen as the best value. Before this
1819// field existed, every ManagementSurface received ModuleManaged delivery (32
1820// concurrent credits) unconditionally, so an absent-field manifest must resolve
1821// to exactly that behavior -- any other default (including the fail-closed
1822// Serial) would convert a daemon upgrade into a silent delivery-semantics
1823// change for every deployed module. A genuinely-Serial module was ALREADY
1824// receiving concurrent delivery under pre-field daemons; the field's addition
1825// is what makes declaring Serial possible at all, so the fix for such a module
1826// is an explicit declaration, and the daemon logs defaulted registrations so
1827// the fleet's exposure is readable rather than assumed.
1828impl Default for Concurrency {
1829    fn default() -> Self {
1830        Self::ModuleManaged
1831    }
1832}
1833
1834/// A `BindIdentity` key a provider partitions its state or answers by.
1835///
1836/// Declared in a role's `identity_scope` to say which caller keys change
1837/// what the module does; the daemon hands every bind all of the keys
1838/// regardless, so an empty declaration means "answers do not depend on the
1839/// caller", never "keys are refused".
1840#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1841#[serde(rename_all = "snake_case")]
1842pub enum IdentityScope {
1843    Session,
1844    Project,
1845}
1846
1847/// Proxy-plane stage kind.
1848#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1849#[serde(rename_all = "snake_case")]
1850pub enum PipelineStageKind {
1851    Transform,
1852    Codec,
1853    Auth,
1854}
1855
1856/// Provider/model selector for a pipeline stage. `"*"` denotes wildcard.
1857#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1858pub struct PipelineAppliesTo {
1859    pub provider: String,
1860    pub model: String,
1861}
1862
1863/// Operation exposed on the management plane.
1864#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1865pub struct ManagementOperation {
1866    pub name: String,
1867    pub kind: ManagementOperationKind,
1868    #[serde(default, skip_serializing_if = "Option::is_none")]
1869    pub description: Option<String>,
1870}
1871
1872#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1873#[serde(rename_all = "snake_case")]
1874pub enum ManagementOperationKind {
1875    Query,
1876    Mutate,
1877}
1878
1879/// Observable state exposed on the management plane.
1880#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1881pub struct ObservabilitySurface {
1882    pub name: String,
1883    pub kind: ObservabilityKind,
1884}
1885
1886#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1887#[serde(rename_all = "snake_case")]
1888pub enum ObservabilityKind {
1889    Snapshot,
1890    Stream,
1891}
1892
1893#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1894#[serde(rename_all = "snake_case")]
1895pub enum InternalTransport {
1896    Bulk,
1897}
1898
1899/// Consumer capabilities requested by a module.
1900#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1901#[serde(tag = "role", rename_all = "snake_case")]
1902pub enum ConsumerRole {
1903    ToolClient { of: Vec<String> },
1904    LlmClient { via: String, auth: String },
1905    ServiceClient { of: Vec<String> },
1906}
1907
1908/// External storage, vault, and identity bindings supplied through subc.
1909#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1910pub struct Bindings {
1911    pub storage: StorageBinding,
1912    pub vault_grants: Vec<VaultGrant>,
1913    pub identity: IdentityBinding,
1914}
1915
1916/// Storage backend supplied by subc; the module owns its schema.
1917#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1918pub struct StorageBinding {
1919    pub kind: StorageKind,
1920    pub scope: StorageScope,
1921    pub owns_schema: bool,
1922}
1923
1924#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1925#[serde(rename_all = "snake_case")]
1926pub enum StorageKind {
1927    Sqlite,
1928}
1929
1930#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1931#[serde(rename_all = "snake_case")]
1932pub enum StorageScope {
1933    Project,
1934}
1935
1936#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1937pub struct VaultGrant {
1938    pub secret: String,
1939    pub reason: String,
1940}
1941
1942#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1943pub struct IdentityBinding {
1944    pub requires: Vec<IdentityScope>,
1945    pub optional: Vec<IdentityScope>,
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951    use serde_json::json;
1952
1953    fn aft_manifest_fixture() -> ModuleManifest {
1954        ModuleManifest::builder("aft", "0.39.2")
1955            .trust_tier(Some(TrustTier::FirstParty))
1956            .bindings(Some(Bindings {
1957                storage: StorageBinding {
1958                    kind: StorageKind::Sqlite,
1959                    scope: StorageScope::Project,
1960                    owns_schema: true,
1961                },
1962                vault_grants: vec![VaultGrant {
1963                    secret: "provider_api_key".to_string(),
1964                    reason: "cortexkit_native auth".to_string(),
1965                }],
1966                identity: IdentityBinding {
1967                    requires: vec![IdentityScope::Project],
1968                    optional: vec![IdentityScope::Session],
1969                },
1970            }))
1971            .protocol_ver(1)
1972            .provides(vec![ProviderRole::ToolProvider {
1973                tools: vec![
1974                    Tool {
1975                        name: "read".to_string(),
1976                        description: None,
1977                        execution_mode: ExecutionMode::Pure,
1978                        schema: json!({"type": "object"}),
1979                    },
1980                    Tool {
1981                        name: "grep".to_string(),
1982                        description: None,
1983                        execution_mode: ExecutionMode::Pure,
1984                        schema: json!({"type": "object"}),
1985                    },
1986                    Tool {
1987                        name: "outline".to_string(),
1988                        description: None,
1989                        execution_mode: ExecutionMode::Pure,
1990                        schema: json!({"type": "object"}),
1991                    },
1992                    Tool {
1993                        name: "semantic_search".to_string(),
1994                        description: None,
1995                        execution_mode: ExecutionMode::Pure,
1996                        schema: json!({"type": "object"}),
1997                    },
1998                    Tool {
1999                        name: "edit".to_string(),
2000                        description: None,
2001                        execution_mode: ExecutionMode::Mutating,
2002                        schema: json!({"type": "object"}),
2003                    },
2004                    Tool {
2005                        name: "write".to_string(),
2006                        description: None,
2007                        execution_mode: ExecutionMode::Mutating,
2008                        schema: json!({"type": "object"}),
2009                    },
2010                    Tool {
2011                        name: "bash".to_string(),
2012                        description: None,
2013                        execution_mode: ExecutionMode::Unfenceable,
2014                        schema: json!({"type": "object"}),
2015                    },
2016                ],
2017                identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
2018                concurrency: Concurrency::ModuleManaged,
2019                emits_push: true,
2020                sub_supervises: true,
2021            }])
2022            .consumes(vec![ConsumerRole::ServiceClient {
2023                of: vec!["embedding.v2".to_string()],
2024            }])
2025            .build()
2026    }
2027
2028    #[test]
2029    fn serde_round_trips_representative_manifest() {
2030        let manifest = aft_manifest_fixture();
2031        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
2032        let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
2033
2034        assert_eq!(manifest, decoded);
2035    }
2036
2037    #[test]
2038    fn builder_defaults_additions_to_honest_absence_and_round_trips() {
2039        let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
2040
2041        assert_eq!(manifest.module_id, "builder-defaults");
2042        assert_eq!(manifest.module_version, "2.0.0");
2043        assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
2044        assert_eq!(manifest.trust_tier, None);
2045        assert!(manifest.provides.is_empty());
2046        assert!(manifest.consumes.is_empty());
2047        assert_eq!(manifest.bindings, None);
2048        assert_eq!(manifest.capabilities, None);
2049        assert_eq!(manifest.self_signals, None);
2050        assert_eq!(manifest.provenance, None);
2051
2052        let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
2053        for optional in [
2054            "trust_tier",
2055            "consumes",
2056            "bindings",
2057            "capabilities",
2058            "self_signals",
2059            "provenance",
2060        ] {
2061            assert!(
2062                encoded.get(optional).is_none(),
2063                "an absent {optional} declaration must stay absent on the wire"
2064            );
2065        }
2066        let decoded: ModuleManifest =
2067            serde_json::from_value(encoded).expect("builder manifest round-trips");
2068        assert_eq!(decoded, manifest);
2069    }
2070
2071    #[test]
2072    fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
2073        let manifest = ModuleManifest::builder("full-builder", "2.0.0")
2074            .trust_tier(Some(TrustTier::Reviewed))
2075            .bindings(Some(Bindings {
2076                storage: StorageBinding {
2077                    kind: StorageKind::Sqlite,
2078                    scope: StorageScope::Project,
2079                    owns_schema: false,
2080                },
2081                vault_grants: Vec::new(),
2082                identity: IdentityBinding {
2083                    requires: vec![IdentityScope::Project],
2084                    optional: Vec::new(),
2085                },
2086            }))
2087            .provides(vec![ProviderRole::ToolProvider {
2088                tools: vec![Tool {
2089                    name: "read".to_string(),
2090                    description: None,
2091                    execution_mode: ExecutionMode::Pure,
2092                    schema: json!({"type": "object"}),
2093                }],
2094                identity_scope: vec![IdentityScope::Project],
2095                concurrency: Concurrency::Serial,
2096                emits_push: false,
2097                sub_supervises: false,
2098            }])
2099            .consumes(vec![ConsumerRole::ServiceClient {
2100                of: vec!["embedding.v2".to_string()],
2101            }])
2102            .capabilities(Some(CapabilityDeclarations {
2103                provides: vec!["embedding/v2".to_string()],
2104                requires: Vec::new(),
2105                must_never_reach: Vec::new(),
2106            }))
2107            .self_signals(Some(vec![SelfSignalDeclaration {
2108                name: "usage_poller".to_string(),
2109                kind: SelfSignalKind::Poller,
2110                effect: SelfSignalEffect::Observe,
2111                anchored_to: SignalAnchor::FixedInterval,
2112                cadence: Some(SignalCadence::Literal {
2113                    interval_ms: 60_000,
2114                }),
2115                domain: Some("provider-usage".to_string()),
2116                note: None,
2117            }]))
2118            .provenance(Some(ManifestProvenance {
2119                build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2120                build_git_sha_absence_reason: None,
2121                build_lock_digest: Some(
2122                    "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2123                ),
2124                wire_crate_version: Some("0.16.0".to_string()),
2125                store_schema_version: Some("42".to_string()),
2126                launch_nonce_source: None,
2127            }))
2128            .build();
2129
2130        assert_eq!(
2131            serde_json::to_vec(&manifest).expect("builder manifest serializes"),
2132            include_bytes!("../tests/golden/module_manifest_builder_full.json"),
2133            "the builder must preserve the prior fully populated literal wire bytes"
2134        );
2135    }
2136
2137    #[test]
2138    fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
2139        let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
2140        let decoded: ModuleManifest =
2141            serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
2142
2143        assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
2144        assert!(!decoded.consumes.is_empty());
2145        assert!(decoded.bindings.is_some());
2146
2147        let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
2148        assert_eq!(
2149            reencoded, raw,
2150            "old manifest relay stays byte-for-byte verbatim"
2151        );
2152    }
2153
2154    #[test]
2155    fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
2156        let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
2157        let decoded: ModuleManifest =
2158            serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
2159
2160        assert_eq!(decoded.trust_tier, None);
2161        assert!(decoded.consumes.is_empty());
2162        assert_eq!(decoded.bindings, None);
2163
2164        let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
2165        assert_eq!(
2166            pretty.as_bytes(),
2167            raw,
2168            "new manifest matches golden byte-for-byte without unread keys"
2169        );
2170
2171        let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
2172        assert!(
2173            as_val.get("trust_tier").is_none(),
2174            "no trust_tier on wire for new manifest"
2175        );
2176        assert!(
2177            as_val.get("consumes").is_none(),
2178            "no consumes on wire for empty consumes"
2179        );
2180        assert!(
2181            as_val.get("bindings").is_none(),
2182            "no bindings on wire for new manifest"
2183        );
2184    }
2185
2186    #[test]
2187    fn aft_manifest_fixture_matches_v1_contract() {
2188        let manifest = aft_manifest_fixture();
2189
2190        assert_eq!(manifest.module_id, "aft");
2191        let ProviderRole::ToolProvider {
2192            tools,
2193            identity_scope,
2194            concurrency,
2195            emits_push,
2196            sub_supervises,
2197        } = &manifest.provides[0]
2198        else {
2199            panic!("AFT fixture must expose one tool_provider role");
2200        };
2201
2202        assert_eq!(*concurrency, Concurrency::ModuleManaged);
2203        assert!(*emits_push);
2204        assert!(*sub_supervises);
2205        assert_eq!(
2206            identity_scope,
2207            &vec![IdentityScope::Session, IdentityScope::Project]
2208        );
2209        assert_eq!(
2210            tools
2211                .iter()
2212                .map(|tool| (tool.name.as_str(), tool.execution_mode))
2213                .collect::<Vec<_>>(),
2214            vec![
2215                ("read", ExecutionMode::Pure),
2216                ("grep", ExecutionMode::Pure),
2217                ("outline", ExecutionMode::Pure),
2218                ("semantic_search", ExecutionMode::Pure),
2219                ("edit", ExecutionMode::Mutating),
2220                ("write", ExecutionMode::Mutating),
2221                ("bash", ExecutionMode::Unfenceable),
2222            ]
2223        );
2224    }
2225
2226    #[test]
2227    fn tool_provider_role_tag_serializes_as_snake_case() {
2228        let manifest = aft_manifest_fixture();
2229        let value = serde_json::to_value(&manifest).unwrap();
2230
2231        assert_eq!(value["provides"][0]["role"], "tool_provider");
2232    }
2233
2234    #[test]
2235    fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
2236        let manifest = aft_manifest_fixture();
2237        let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
2238        assert!(encoded.get("capabilities").is_none());
2239
2240        let decoded: ModuleManifest =
2241            serde_json::from_value(encoded).expect("legacy manifest parses");
2242        assert_eq!(decoded.capabilities, None);
2243    }
2244
2245    #[test]
2246    fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
2247        for identifier in [
2248            "a/v1",
2249            "credentials-provider/v1",
2250            "a1-b2/v4294967295",
2251            "a123456789012345678901234567890123456789012345678901234567890123/v1",
2252        ] {
2253            assert!(
2254                is_valid_capability_identifier(identifier),
2255                "identifier must be accepted: {identifier}"
2256            );
2257        }
2258
2259        for identifier in [
2260            "credentials-Provider/v1",
2261            "credentials-provider/v01",
2262            "credentials-provider-/v1",
2263            "credentials--provider/v1",
2264            "Credentials-provider/v1",
2265            "credentials-provider/1",
2266            "credentials provider/v1",
2267            "credentials-provider/v0",
2268            "credentials-provider/v4294967296",
2269            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
2270        ] {
2271            assert!(
2272                !is_valid_capability_identifier(identifier),
2273                "identifier must be rejected: {identifier}"
2274            );
2275        }
2276    }
2277
2278    #[test]
2279    fn capability_grammar_errors_redact_secret_shaped_values() {
2280        let error = validate_manifest_capability_grammar(&json!({
2281            "capabilities": { "provides": ["sk-secret-value/v0"] }
2282        }))
2283        .expect_err("secret-shaped capability identifier is malformed");
2284        assert_eq!(error.field(), "capabilities.provides[0]");
2285        assert_eq!(error.value(), "<redacted>");
2286        assert!(!error.to_string().contains("sk-secret-value"));
2287    }
2288
2289    /// Builder sentinels are the strings tooling emits where it means "no
2290    /// value" (shell fallbacks say `unknown`, not `unavailable`); publishing
2291    /// one as a build fact is the well-formed lie the provenance contract
2292    /// names. The helper must map every sentinel, any casing, to field
2293    /// omission — and must keep a canonical real value intact (the control arm,
2294    /// so the filter cannot pass by refusing everything).
2295    #[test]
2296    fn provenance_builder_sentinels_become_field_omission() {
2297        for sentinel in [
2298            "unknown",
2299            "UNKNOWN",
2300            "Unknown",
2301            "unavailable",
2302            "none",
2303            "None",
2304            "  unknown  ",
2305            "",
2306        ] {
2307            let p = build_provenance_from_source(
2308                BuildGitShaSource::Git {
2309                    revision: sentinel,
2310                    tree_state: GitTreeState::Clean,
2311                },
2312                Some(sentinel),
2313                Some(sentinel),
2314            )
2315            .expect("sentinels are omitted before form validation");
2316            assert_eq!(
2317                (
2318                    p.build_git_sha,
2319                    p.build_git_sha_absence_reason,
2320                    p.build_lock_digest,
2321                    p.store_schema_version,
2322                ),
2323                (
2324                    None,
2325                    Some(BuildGitShaAbsenceReason::NeverDerived),
2326                    None,
2327                    None,
2328                ),
2329                "sentinel {sentinel:?} must be omitted, not published"
2330            );
2331        }
2332        let real = build_provenance_from_source(
2333            BuildGitShaSource::Git {
2334                revision: "0123456789abcdef0123456789abcdef01234567",
2335                tree_state: GitTreeState::Clean,
2336            },
2337            None,
2338            Some("9"),
2339        )
2340        .expect("canonical build revision is accepted");
2341        assert_eq!(
2342            real.build_git_sha.as_deref(),
2343            Some("0123456789abcdef0123456789abcdef01234567")
2344        );
2345        assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2346        // The always-knowable fact: an SDK-built block always carries a crate
2347        // version, so it is never empty; that is why the contract omits a
2348        // field when it is absent rather than publishing a sentinel.
2349        assert_eq!(
2350            real.wire_crate_version.as_deref(),
2351            Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2352        );
2353    }
2354
2355    #[test]
2356    fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2357        let provenance = build_provenance_from_source(
2358            BuildGitShaSource::Git {
2359                revision: " 0123456789abcdef0123456789abcdef01234567 ",
2360                tree_state: GitTreeState::Clean,
2361            },
2362            Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2363            Some(" schema-v3 "),
2364        )
2365        .expect("canonical build facts are accepted");
2366
2367        assert_eq!(
2368            provenance,
2369            ManifestProvenance {
2370                build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2371                build_git_sha_absence_reason: None,
2372                build_lock_digest: Some(
2373                    "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2374                ),
2375                wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2376                store_schema_version: Some("schema-v3".to_string()),
2377                launch_nonce_source: None,
2378            }
2379        );
2380    }
2381
2382    #[test]
2383    fn build_provenance_refuses_an_abbreviated_git_sha() {
2384        let error = build_provenance_from_source(
2385            BuildGitShaSource::Git {
2386                revision: "0123456789ab",
2387                tree_state: GitTreeState::Clean,
2388            },
2389            None,
2390            None,
2391        )
2392        .expect_err("a 12-character abbreviation is not canonical");
2393
2394        assert_eq!(error.field(), "build_git_sha");
2395        assert_eq!(error.length(), 12);
2396        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2397        assert_eq!(
2398            error.to_string(),
2399            "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2400        );
2401    }
2402
2403    #[test]
2404    fn build_provenance_refuses_an_abbreviated_lock_digest() {
2405        let error = build_provenance_from_source(
2406            BuildGitShaSource::NeverDerived,
2407            Some("0123456789abcdef"),
2408            None,
2409        )
2410        .expect_err("a 16-character digest is not canonical");
2411
2412        assert_eq!(error.field(), "build_lock_digest");
2413        assert_eq!(error.length(), 16);
2414        assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2415    }
2416
2417    #[test]
2418    fn build_provenance_refuses_uppercase_hex() {
2419        let uppercase_sha = "A".repeat(40);
2420        let error = build_provenance_from_source(
2421            BuildGitShaSource::Git {
2422                revision: &uppercase_sha,
2423                tree_state: GitTreeState::Clean,
2424            },
2425            None,
2426            None,
2427        )
2428        .expect_err("uppercase hexadecimal is not canonical");
2429
2430        assert_eq!(error.field(), "build_git_sha");
2431        assert_eq!(error.length(), 40);
2432        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2433    }
2434
2435    #[test]
2436    fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2437        let error = build_provenance_from_source(
2438            BuildGitShaSource::Git {
2439                revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2440                tree_state: GitTreeState::Clean,
2441            },
2442            None,
2443            None,
2444        )
2445        .expect_err("a dirty stamp is not a canonical build revision");
2446
2447        assert_eq!(error.field(), "build_git_sha");
2448        assert_eq!(error.length(), 46);
2449        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2450    }
2451
2452    #[test]
2453    fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2454        let provenance = build_provenance_from_source(
2455            BuildGitShaSource::Git {
2456                revision: "unavailable",
2457                tree_state: GitTreeState::Clean,
2458            },
2459            Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2460            None,
2461        )
2462        .expect("sentinel SHA is omitted before the valid lock digest is checked");
2463
2464        assert_eq!(provenance.build_git_sha, None);
2465        assert_eq!(
2466            provenance.build_lock_digest,
2467            Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2468        );
2469        assert_eq!(
2470            provenance.wire_crate_version,
2471            Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2472        );
2473    }
2474
2475    #[test]
2476    fn build_provenance_omits_fully_unavailable_inputs() {
2477        let provenance = build_provenance_from_source(
2478            BuildGitShaSource::NeverDerived,
2479            Some(" unavailable "),
2480            Some("   "),
2481        )
2482        .expect("omitted and sentinel inputs are not form errors");
2483
2484        assert_eq!(provenance.build_git_sha, None);
2485        assert_eq!(provenance.build_lock_digest, None);
2486        assert_eq!(provenance.store_schema_version, None);
2487        assert_eq!(
2488            provenance.wire_crate_version,
2489            Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2490        );
2491    }
2492
2493    #[test]
2494    fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2495        let revision = "0123456789abcdef0123456789abcdef01234567";
2496        for (input, expected) in [
2497            (
2498                Some(revision),
2499                format!(
2500                    r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2501                    crate::SUBC_PROTOCOL_CRATE_VERSION
2502                ),
2503            ),
2504            (
2505                None,
2506                format!(
2507                    r#"{{"wire_crate_version":"{}"}}"#,
2508                    crate::SUBC_PROTOCOL_CRATE_VERSION
2509                ),
2510            ),
2511            (
2512                Some("unknown"),
2513                format!(
2514                    r#"{{"wire_crate_version":"{}"}}"#,
2515                    crate::SUBC_PROTOCOL_CRATE_VERSION
2516                ),
2517            ),
2518        ] {
2519            let provenance = build_provenance(input, None, None)
2520                .expect("the legacy build facts remain constructible");
2521            assert_eq!(provenance.build_git_sha_absence_reason, None);
2522            assert_eq!(
2523                serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2524                expected
2525            );
2526        }
2527    }
2528
2529    #[test]
2530    fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2531        let revision = "0123456789abcdef0123456789abcdef01234567";
2532        let cases = [
2533            (
2534                BuildGitShaSource::Git {
2535                    revision,
2536                    tree_state: GitTreeState::Clean,
2537                },
2538                Some(revision),
2539                None,
2540            ),
2541            (
2542                BuildGitShaSource::Git {
2543                    revision,
2544                    tree_state: GitTreeState::Dirty,
2545                },
2546                None,
2547                Some(BuildGitShaAbsenceReason::DeclinedDirty),
2548            ),
2549            (
2550                BuildGitShaSource::NeverDerived,
2551                None,
2552                Some(BuildGitShaAbsenceReason::NeverDerived),
2553            ),
2554            (
2555                BuildGitShaSource::NoGitDir,
2556                None,
2557                Some(BuildGitShaAbsenceReason::NoGitDir),
2558            ),
2559        ];
2560
2561        for (source, expected_sha, expected_reason) in cases {
2562            let provenance = build_provenance_from_source(source, None, None)
2563                .expect("every stamping state constructs honest provenance");
2564            assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2565            assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2566        }
2567    }
2568
2569    #[test]
2570    fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2571        let wire = format!(
2572            r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2573            crate::SUBC_PROTOCOL_CRATE_VERSION
2574        );
2575        let provenance: ManifestProvenance =
2576            serde_json::from_str(&wire).expect("future absence reasons remain readable");
2577
2578        assert_eq!(
2579            provenance.build_git_sha_absence_reason,
2580            Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2581                "future_stamper_state".to_string()
2582            ))
2583        );
2584        assert_eq!(
2585            serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2586            wire
2587        );
2588    }
2589
2590    #[test]
2591    fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2592        let error = serde_json::from_value::<ManifestProvenance>(json!({
2593            "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2594            "build_git_sha_absence_reason": "declined_dirty"
2595        }))
2596        .expect_err("a declared commit cannot also claim an absence reason");
2597
2598        assert!(error.to_string().contains(
2599            "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2600        ));
2601    }
2602}