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