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), `pipe` (the Windows named pipe),
828    /// or `env` (the environment
829    /// variable kept while modules move to the pipe). Absent means the module
830    /// did not say. This is a fact about the running process, not the build.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub launch_nonce_source: Option<LaunchNonceSource>,
833}
834
835/// Where a module read its launch nonce from, as reported in
836/// [`ManifestProvenance::launch_nonce_source`].
837///
838/// An open string enum, like [`BuildGitShaAbsenceReason`]: a value this crate
839/// does not know decodes as `ForwardCompatibleUnknown` instead of making the
840/// whole provenance block, and with it the module's HELLO, fail to decode.
841#[derive(Debug, Clone, PartialEq, Eq)]
842#[non_exhaustive]
843pub enum LaunchNonceSource {
844    /// Read from the inherited descriptor the daemon passes.
845    Fd,
846    /// Read from the PID-authenticated Windows named pipe.
847    Pipe,
848    /// Read from the `SUBC_LAUNCH_NONCE` environment variable.
849    Env,
850    ForwardCompatibleUnknown(String),
851}
852
853impl LaunchNonceSource {
854    /// The value as it appears on the wire.
855    pub fn wire_name(&self) -> &str {
856        match self {
857            Self::Fd => "fd",
858            Self::Pipe => "pipe",
859            Self::Env => "env",
860            Self::ForwardCompatibleUnknown(value) => value,
861        }
862    }
863
864    /// The source a wire value names; unknown values are kept as they are.
865    pub fn from_wire_name(value: &str) -> Self {
866        match value {
867            "fd" => Self::Fd,
868            "pipe" => Self::Pipe,
869            "env" => Self::Env,
870            _ => Self::ForwardCompatibleUnknown(value.to_string()),
871        }
872    }
873}
874
875impl Serialize for LaunchNonceSource {
876    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
877    where
878        S: serde::Serializer,
879    {
880        serializer.serialize_str(self.wire_name())
881    }
882}
883
884impl<'de> Deserialize<'de> for LaunchNonceSource {
885    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
886    where
887        D: serde::Deserializer<'de>,
888    {
889        let value = String::deserialize(deserializer)?;
890        Ok(Self::from_wire_name(&value))
891    }
892}
893
894/// A build pipeline's reason for omitting `build_git_sha`.
895///
896/// This is an open string enum: consumers preserve a future reason instead of
897/// rejecting the enclosing provenance declaration.
898#[derive(Debug, Clone, PartialEq, Eq)]
899pub enum BuildGitShaAbsenceReason {
900    DeclinedDirty,
901    NeverDerived,
902    NoGitDir,
903    ForwardCompatibleUnknown(String),
904}
905
906impl BuildGitShaAbsenceReason {
907    fn wire_name(&self) -> &str {
908        match self {
909            Self::DeclinedDirty => "declined_dirty",
910            Self::NeverDerived => "never_derived",
911            Self::NoGitDir => "no_git_dir",
912            Self::ForwardCompatibleUnknown(value) => value,
913        }
914    }
915}
916
917impl Serialize for BuildGitShaAbsenceReason {
918    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
919    where
920        S: serde::Serializer,
921    {
922        serializer.serialize_str(self.wire_name())
923    }
924}
925
926impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
927    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
928    where
929        D: serde::Deserializer<'de>,
930    {
931        let value = String::deserialize(deserializer)?;
932        Ok(match value.as_str() {
933            "declined_dirty" => Self::DeclinedDirty,
934            "never_derived" => Self::NeverDerived,
935            "no_git_dir" => Self::NoGitDir,
936            _ => Self::ForwardCompatibleUnknown(value),
937        })
938    }
939}
940
941/// The observable state of a git worktree when a build pipeline found a revision.
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943pub enum GitTreeState {
944    Clean,
945    Dirty,
946}
947
948/// How the build pipeline obtained (or did not obtain) git revision data.
949///
950/// `NoGitDir` and `NeverDerived` carry no revision, so callers cannot attach
951/// those absence causes to an otherwise attested commit through this API.
952#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953pub enum BuildGitShaSource<'a> {
954    Git {
955        revision: &'a str,
956        tree_state: GitTreeState,
957    },
958    NeverDerived,
959    NoGitDir,
960}
961
962/// Return a commit only when its source tree was clean at build time.
963///
964/// The rule is pure so stampers can exercise both branches without rebuilding.
965pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
966    match tree_state {
967        GitTreeState::Clean => Some(revision),
968        GitTreeState::Dirty => None,
969    }
970}
971
972const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
973const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
974const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
975
976#[derive(Deserialize)]
977struct ManifestProvenanceWire {
978    #[serde(default)]
979    build_git_sha: Option<String>,
980    #[serde(default)]
981    build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
982    #[serde(default)]
983    build_lock_digest: Option<String>,
984    #[serde(default)]
985    wire_crate_version: Option<String>,
986    #[serde(default)]
987    store_schema_version: Option<String>,
988    #[serde(default)]
989    launch_nonce_source: Option<LaunchNonceSource>,
990}
991
992impl<'de> Deserialize<'de> for ManifestProvenance {
993    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
994    where
995        D: Deserializer<'de>,
996    {
997        let wire = ManifestProvenanceWire::deserialize(deserializer)?;
998        let provenance = Self {
999            build_git_sha: wire.build_git_sha,
1000            build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
1001            build_lock_digest: wire.build_lock_digest,
1002            wire_crate_version: wire.wire_crate_version,
1003            store_schema_version: wire.store_schema_version,
1004            launch_nonce_source: wire.launch_nonce_source,
1005        };
1006        provenance.validate().map_err(D::Error::custom)?;
1007        Ok(provenance)
1008    }
1009}
1010
1011/// A declared build fact did not use its canonical form.
1012#[derive(Debug, Clone, PartialEq, Eq)]
1013pub struct ProvenanceFormError {
1014    field: &'static str,
1015    length: usize,
1016    canonical_form: &'static str,
1017}
1018
1019impl ProvenanceFormError {
1020    fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
1021        Self {
1022            field,
1023            length,
1024            canonical_form,
1025        }
1026    }
1027
1028    /// The provenance field whose value was not canonical.
1029    pub fn field(&self) -> &str {
1030        self.field
1031    }
1032
1033    /// The offending value's length in bytes.
1034    pub fn length(&self) -> usize {
1035        self.length
1036    }
1037
1038    /// The canonical form required for this field.
1039    pub fn canonical_form(&self) -> &str {
1040        self.canonical_form
1041    }
1042}
1043
1044impl fmt::Display for ProvenanceFormError {
1045    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1046        write!(
1047            f,
1048            "invalid manifest provenance form: field {} has length {}; canonical form is {}",
1049            self.field, self.length, self.canonical_form
1050        )
1051    }
1052}
1053
1054impl std::error::Error for ProvenanceFormError {}
1055
1056#[derive(Debug, Clone, PartialEq, Eq)]
1057pub struct ManifestProvenanceError {
1058    field: String,
1059    value: String,
1060    reason: &'static str,
1061}
1062
1063impl ManifestProvenanceError {
1064    fn new(field: &str, value: &str, reason: &'static str) -> Self {
1065        Self {
1066            field: field.to_string(),
1067            value: safe_error_value(value),
1068            reason,
1069        }
1070    }
1071
1072    pub fn field(&self) -> &str {
1073        &self.field
1074    }
1075}
1076
1077impl fmt::Display for ManifestProvenanceError {
1078    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1079        write!(
1080            f,
1081            "invalid manifest provenance: field {} has {} (value {:?})",
1082            self.field, self.reason, self.value
1083        )
1084    }
1085}
1086
1087impl std::error::Error for ManifestProvenanceError {}
1088
1089impl ManifestProvenance {
1090    /// A block declaring nothing; add facts with the `with_*` setters.
1091    pub fn new() -> Self {
1092        Self::default()
1093    }
1094
1095    pub fn with_build_git_sha(mut self, value: Option<String>) -> Self {
1096        self.build_git_sha = value;
1097        self
1098    }
1099
1100    pub fn with_build_git_sha_absence_reason(
1101        mut self,
1102        value: Option<BuildGitShaAbsenceReason>,
1103    ) -> Self {
1104        self.build_git_sha_absence_reason = value;
1105        self
1106    }
1107
1108    pub fn with_build_lock_digest(mut self, value: Option<String>) -> Self {
1109        self.build_lock_digest = value;
1110        self
1111    }
1112
1113    pub fn with_wire_crate_version(mut self, value: Option<String>) -> Self {
1114        self.wire_crate_version = value;
1115        self
1116    }
1117
1118    pub fn with_store_schema_version(mut self, value: Option<String>) -> Self {
1119        self.store_schema_version = value;
1120        self
1121    }
1122
1123    /// Report where this process read its launch nonce from. A module takes
1124    /// it from its nonce accessor's cached source, so whoever reads the fleet's
1125    /// provenance (`ck provenance`) can tell a module reading the pipe from
1126    /// one still reading the environment variable.
1127    pub fn with_launch_nonce_source(mut self, value: Option<LaunchNonceSource>) -> Self {
1128        self.launch_nonce_source = value;
1129        self
1130    }
1131
1132    pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
1133        if let (Some(_), Some(reason)) = (
1134            self.build_git_sha.as_ref(),
1135            self.build_git_sha_absence_reason.as_ref(),
1136        ) {
1137            return Err(ManifestProvenanceError::new(
1138                "build_git_sha_absence_reason",
1139                reason.wire_name(),
1140                "must be omitted when build_git_sha is present",
1141            ));
1142        }
1143        for (field, value) in [
1144            ("build_git_sha", self.build_git_sha.as_deref()),
1145            (
1146                "build_git_sha_absence_reason",
1147                self.build_git_sha_absence_reason
1148                    .as_ref()
1149                    .map(|reason| reason.wire_name()),
1150            ),
1151            ("build_lock_digest", self.build_lock_digest.as_deref()),
1152            ("wire_crate_version", self.wire_crate_version.as_deref()),
1153            ("store_schema_version", self.store_schema_version.as_deref()),
1154            (
1155                "launch_nonce_source",
1156                self.launch_nonce_source
1157                    .as_ref()
1158                    .map(|source| source.wire_name()),
1159            ),
1160        ] {
1161            let Some(value) = value else { continue };
1162            if value.is_empty() {
1163                return Err(ManifestProvenanceError::new(
1164                    field,
1165                    value,
1166                    "must not be empty",
1167                ));
1168            }
1169            // HELLO decoding checks only wire safety here. Canonical build forms
1170            // belong to build_provenance; the daemon is a non-adjudicating relayer
1171            // and must continue accepting legacy declarations such as 12-hex
1172            // module revisions rather than breaking a fleet on daemon upgrade.
1173            if value.len() > MAX_PROVENANCE_VALUE_BYTES {
1174                return Err(ManifestProvenanceError::new(
1175                    field,
1176                    value,
1177                    "exceeds the 128-byte maximum",
1178                ));
1179            }
1180            if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
1181                return Err(ManifestProvenanceError::new(
1182                    field,
1183                    value,
1184                    "contains non-printable ASCII",
1185                ));
1186            }
1187        }
1188        Ok(())
1189    }
1190}
1191
1192/// Build [`ManifestProvenance`] from legacy raw build facts.
1193///
1194/// Callers of this compatibility path did not supply the tree state that
1195/// explains an omitted SHA. It emits no absence reason not because the absence
1196/// has no cause, but because guessing one without that state would fabricate
1197/// the fact this API exists to report honestly.
1198///
1199/// ```
1200/// use subc_protocol::manifest::build_provenance;
1201///
1202/// let provenance = build_provenance(option_env!("CK_BUILD_REV"), None, None)
1203///     .expect("legacy build facts remain supported");
1204/// assert!(provenance.build_git_sha_absence_reason.is_none());
1205/// ```
1206///
1207/// Sentinel and empty values become field omission before canonical form
1208/// validation, preserving the established three-argument wire behavior.
1209pub fn build_provenance(
1210    build_git_sha: Option<&str>,
1211    build_lock_digest: Option<&str>,
1212    store_schema_version: Option<&str>,
1213) -> Result<ManifestProvenance, ProvenanceFormError> {
1214    let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
1215    build_provenance_with_build_git_sha(
1216        build_git_sha,
1217        None,
1218        build_lock_digest,
1219        store_schema_version,
1220    )
1221}
1222
1223/// Build a [`ManifestProvenance`] from source-state-aware build facts.
1224///
1225/// The source state makes the SHA absence cause attestable: `Dirty` declines
1226/// the commit, while `NeverDerived` and `NoGitDir` name distinct source paths.
1227/// A `build_git_sha` must be exactly 40 lowercase hexadecimal characters and a
1228/// `build_lock_digest` exactly 64 lowercase hexadecimal characters. Abbreviations
1229/// are not conforming; a real value in the wrong form returns a
1230/// [`ProvenanceFormError`] instead of being discarded as if it were absent.
1231/// Sentinel values are filtered before form validation, so they remain honest
1232/// omission rather than becoming form errors.
1233///
1234/// OWNERSHIP RULE: a helper that constructs a wire type lives in the crate
1235/// that owns the type. This helper constructs `ManifestProvenance`, so it
1236/// lives here in subc-protocol (not in subc-client-rs) — transport-direct
1237/// modules that never link the client SDK can still build honest provenance.
1238pub fn build_provenance_from_source(
1239    build_git_sha_source: BuildGitShaSource<'_>,
1240    build_lock_digest: Option<&str>,
1241    store_schema_version: Option<&str>,
1242) -> Result<ManifestProvenance, ProvenanceFormError> {
1243    let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
1244        BuildGitShaSource::Git {
1245            revision,
1246            tree_state,
1247        } => match attestable_commit(revision, tree_state) {
1248            Some(revision) => (Some(revision), None),
1249            None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
1250        },
1251        BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
1252        BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
1253    };
1254    let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
1255    if build_git_sha.is_none() {
1256        build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
1257    }
1258    build_provenance_with_build_git_sha(
1259        build_git_sha,
1260        build_git_sha_absence_reason,
1261        build_lock_digest,
1262        store_schema_version,
1263    )
1264}
1265
1266fn normalize_and_validate_build_git_sha(
1267    build_git_sha: Option<&str>,
1268) -> Result<Option<String>, ProvenanceFormError> {
1269    let build_git_sha = normalize_provenance_fact(build_git_sha);
1270    validate_provenance_form(
1271        "build_git_sha",
1272        build_git_sha.as_deref(),
1273        BUILD_GIT_SHA_CANONICAL_FORM,
1274        40,
1275    )?;
1276    Ok(build_git_sha)
1277}
1278
1279fn build_provenance_with_build_git_sha(
1280    build_git_sha: Option<String>,
1281    build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
1282    build_lock_digest: Option<&str>,
1283    store_schema_version: Option<&str>,
1284) -> Result<ManifestProvenance, ProvenanceFormError> {
1285    let build_lock_digest = normalize_provenance_fact(build_lock_digest);
1286    validate_provenance_form(
1287        "build_lock_digest",
1288        build_lock_digest.as_deref(),
1289        BUILD_LOCK_DIGEST_CANONICAL_FORM,
1290        64,
1291    )?;
1292
1293    Ok(ManifestProvenance {
1294        build_git_sha,
1295        build_git_sha_absence_reason,
1296        build_lock_digest,
1297        wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1298        store_schema_version: normalize_provenance_fact(store_schema_version),
1299        launch_nonce_source: None,
1300    })
1301}
1302
1303fn validate_provenance_form(
1304    field: &'static str,
1305    value: Option<&str>,
1306    canonical_form: &'static str,
1307    expected_length: usize,
1308) -> Result<(), ProvenanceFormError> {
1309    let Some(value) = value else { return Ok(()) };
1310    if value.len() != expected_length
1311        || !value
1312            .bytes()
1313            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1314    {
1315        return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
1316    }
1317    Ok(())
1318}
1319
1320/// Sentinel strings that build tooling emits where it means "no value": shell
1321/// fallbacks and Makefile defaults produce `unknown`, wire vocabulary uses
1322/// `unavailable`, and `git describe` failures surface as `none`. Publishing
1323/// any of them as a fact is the well-formed-lie shape the provenance contract
1324/// warns against — a present, well-formed field stops the reader asking — so
1325/// the helper maps them all to field omission. Matched case-insensitively
1326/// because `UNKNOWN`/`Unknown` are equally common from shell fallbacks.
1327pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
1328
1329fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
1330    let value = value?.trim();
1331    if value.is_empty() {
1332        return None;
1333    }
1334    let lowered = value.to_ascii_lowercase();
1335    if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
1336        return None;
1337    }
1338    Some(value.to_string())
1339}
1340
1341/// One capability a module consumes and whether its absence is tolerated.
1342#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1343#[serde(deny_unknown_fields)]
1344pub struct CapabilityRequirement {
1345    pub capability: String,
1346    pub need: CapabilityNeed,
1347}
1348
1349/// Closed capability requirement strength vocabulary.
1350#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1351#[serde(rename_all = "snake_case")]
1352pub enum CapabilityNeed {
1353    Required,
1354    Optional,
1355}
1356
1357/// A safe-to-report capability-schema validation failure.
1358#[derive(Debug, Clone, PartialEq, Eq)]
1359pub struct CapabilityGrammarError {
1360    field: String,
1361    value: String,
1362}
1363
1364impl CapabilityGrammarError {
1365    fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1366        Self {
1367            field: field.into(),
1368            value: safe_error_value(value.as_ref()),
1369        }
1370    }
1371
1372    /// The precise malformed field path.
1373    pub fn field(&self) -> &str {
1374        &self.field
1375    }
1376
1377    /// The offending value, redacted when it resembles a credential.
1378    pub fn value(&self) -> &str {
1379        &self.value
1380    }
1381}
1382
1383impl fmt::Display for CapabilityGrammarError {
1384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1385        write!(
1386            f,
1387            "invalid capability grammar: field {} has offending value {:?}",
1388            self.field, self.value
1389        )
1390    }
1391}
1392
1393impl std::error::Error for CapabilityGrammarError {}
1394
1395impl ModuleManifest {
1396    /// Validate the typed capability block after serde has decoded it.
1397    pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1398        let Some(capabilities) = &self.capabilities else {
1399            return Ok(());
1400        };
1401
1402        validate_capability_list("capabilities.provides", &capabilities.provides)?;
1403        validate_requires(&capabilities.requires)?;
1404        validate_capability_list(
1405            "capabilities.must_never_reach",
1406            &capabilities.must_never_reach,
1407        )
1408    }
1409}
1410
1411/// Validate capability grammar in a standalone manifest JSON value.
1412///
1413/// The raw-value form lets HELLO distinguish schema failures from malformed JSON,
1414/// including an unknown `need` that cannot be represented by [`CapabilityNeed`].
1415pub fn validate_manifest_capability_grammar(
1416    manifest: &Value,
1417) -> Result<(), CapabilityGrammarError> {
1418    let Some(object) = manifest.as_object() else {
1419        return Ok(());
1420    };
1421
1422    validate_capabilities_value(object.get("capabilities"))?;
1423    validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1424}
1425
1426/// Validate capability grammar in a raw HELLO body.
1427///
1428/// `runtime_computed` is a top-level sibling in --manifest output. HELLO keeps
1429/// accepting that sibling only so an attempted dynamic capability declaration is
1430/// refused explicitly instead of being silently ignored by serde.
1431pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1432    let Some(object) = hello.as_object() else {
1433        return Ok(());
1434    };
1435    if let Some(manifest) = object.get("manifest") {
1436        validate_manifest_capability_grammar(manifest)?;
1437    }
1438    validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1439}
1440
1441/// Return whether `identifier` has the exact `<name>/v<N>` capability spelling.
1442pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1443    if identifier.chars().any(char::is_whitespace) {
1444        return false;
1445    }
1446    let Some((name, version)) = identifier.split_once("/v") else {
1447        return false;
1448    };
1449    if name.is_empty() || name.len() > 64 || version.is_empty() {
1450        return false;
1451    }
1452
1453    let name_bytes = name.as_bytes();
1454    if !name_bytes[0].is_ascii_lowercase()
1455        || (name.len() > 1
1456            && !name_bytes[name.len() - 1].is_ascii_lowercase()
1457            && !name_bytes[name.len() - 1].is_ascii_digit())
1458        || name_bytes.windows(2).any(|pair| pair == b"--")
1459    {
1460        return false;
1461    }
1462    if !name_bytes
1463        .iter()
1464        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1465    {
1466        return false;
1467    }
1468
1469    if version.len() > 1 && version.starts_with('0')
1470        || !version.bytes().all(|byte| byte.is_ascii_digit())
1471    {
1472        return false;
1473    }
1474    matches!(
1475        version.parse::<u64>(),
1476        Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1477    )
1478}
1479
1480fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1481    let Some(value) = value else {
1482        return Ok(());
1483    };
1484    let Some(object) = value.as_object() else {
1485        return Err(CapabilityGrammarError::new(
1486            "capabilities",
1487            value_description(value),
1488        ));
1489    };
1490
1491    for (key, value) in object {
1492        if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1493            return Err(CapabilityGrammarError::new(
1494                field_child("capabilities", key),
1495                value_description(value),
1496            ));
1497        }
1498    }
1499
1500    validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1501    validate_requires_value(object.get("requires"))?;
1502    validate_capability_list_value(
1503        "capabilities.must_never_reach",
1504        object.get("must_never_reach"),
1505    )
1506}
1507
1508fn validate_capability_list_value(
1509    field: &str,
1510    value: Option<&Value>,
1511) -> Result<(), CapabilityGrammarError> {
1512    let Some(value) = value else {
1513        return Ok(());
1514    };
1515    let Some(values) = value.as_array() else {
1516        return Err(CapabilityGrammarError::new(field, value_description(value)));
1517    };
1518
1519    let mut seen = HashSet::new();
1520    for (index, value) in values.iter().enumerate() {
1521        let field = format!("{field}[{index}]");
1522        let Some(identifier) = value.as_str() else {
1523            return Err(CapabilityGrammarError::new(field, value_description(value)));
1524        };
1525        validate_capability_identifier(&field, identifier)?;
1526        if !seen.insert(identifier) {
1527            return Err(CapabilityGrammarError::new(field, identifier));
1528        }
1529    }
1530    Ok(())
1531}
1532
1533fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1534    let Some(value) = value else {
1535        return Ok(());
1536    };
1537    let Some(values) = value.as_array() else {
1538        return Err(CapabilityGrammarError::new(
1539            "capabilities.requires",
1540            value_description(value),
1541        ));
1542    };
1543
1544    let mut seen = HashSet::new();
1545    for (index, value) in values.iter().enumerate() {
1546        let entry_field = format!("capabilities.requires[{index}]");
1547        let Some(object) = value.as_object() else {
1548            return Err(CapabilityGrammarError::new(
1549                entry_field,
1550                value_description(value),
1551            ));
1552        };
1553        for (key, value) in object {
1554            if !matches!(key.as_str(), "capability" | "need") {
1555                return Err(CapabilityGrammarError::new(
1556                    field_child(&entry_field, key),
1557                    value_description(value),
1558                ));
1559            }
1560        }
1561        let capability_field = format!("{entry_field}.capability");
1562        let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1563            return Err(CapabilityGrammarError::new(
1564                capability_field,
1565                object
1566                    .get("capability")
1567                    .map_or("<missing>".to_string(), value_description),
1568            ));
1569        };
1570        validate_capability_identifier(&capability_field, capability)?;
1571
1572        let need_field = format!("{entry_field}.need");
1573        let Some(need) = object.get("need").and_then(Value::as_str) else {
1574            return Err(CapabilityGrammarError::new(
1575                need_field,
1576                object
1577                    .get("need")
1578                    .map_or("<missing>".to_string(), value_description),
1579            ));
1580        };
1581        if !matches!(need, "required" | "optional") {
1582            return Err(CapabilityGrammarError::new(need_field, need));
1583        }
1584        if !seen.insert(capability) {
1585            return Err(CapabilityGrammarError::new(entry_field, capability));
1586        }
1587    }
1588    Ok(())
1589}
1590
1591fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1592    let mut seen = HashSet::new();
1593    for (index, identifier) in values.iter().enumerate() {
1594        let field = format!("{field}[{index}]");
1595        validate_capability_identifier(&field, identifier)?;
1596        if !seen.insert(identifier) {
1597            return Err(CapabilityGrammarError::new(field, identifier));
1598        }
1599    }
1600    Ok(())
1601}
1602
1603fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1604    let mut seen = HashSet::new();
1605    for (index, requirement) in values.iter().enumerate() {
1606        let field = format!("capabilities.requires[{index}].capability");
1607        validate_capability_identifier(&field, &requirement.capability)?;
1608        if !seen.insert(&requirement.capability) {
1609            return Err(CapabilityGrammarError::new(
1610                format!("capabilities.requires[{index}]"),
1611                &requirement.capability,
1612            ));
1613        }
1614    }
1615    Ok(())
1616}
1617
1618fn validate_capability_identifier(
1619    field: &str,
1620    identifier: &str,
1621) -> Result<(), CapabilityGrammarError> {
1622    if is_valid_capability_identifier(identifier) {
1623        Ok(())
1624    } else {
1625        Err(CapabilityGrammarError::new(field, identifier))
1626    }
1627}
1628
1629fn validate_runtime_computed(
1630    value: Option<&Value>,
1631    field: &str,
1632) -> Result<(), CapabilityGrammarError> {
1633    let Some(value) = value else {
1634        return Ok(());
1635    };
1636    let Some(pointers) = value.as_array() else {
1637        return Err(CapabilityGrammarError::new(field, value_description(value)));
1638    };
1639
1640    for (index, pointer) in pointers.iter().enumerate() {
1641        let field = format!("{field}[{index}]");
1642        let Some(pointer) = pointer.as_str() else {
1643            return Err(CapabilityGrammarError::new(
1644                field,
1645                value_description(pointer),
1646            ));
1647        };
1648        let Some(tokens) = parse_json_pointer(pointer) else {
1649            return Err(CapabilityGrammarError::new(field, pointer));
1650        };
1651        if tokens.first().is_some_and(|token| token == "capabilities") {
1652            return Err(CapabilityGrammarError::new(field, pointer));
1653        }
1654    }
1655    Ok(())
1656}
1657
1658fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1659    if pointer.is_empty() {
1660        return Some(Vec::new());
1661    }
1662    let raw_tokens = pointer.strip_prefix('/')?;
1663    raw_tokens
1664        .split('/')
1665        .map(unescape_json_pointer_token)
1666        .collect()
1667}
1668
1669fn unescape_json_pointer_token(token: &str) -> Option<String> {
1670    let mut output = String::with_capacity(token.len());
1671    let mut characters = token.chars();
1672    while let Some(character) = characters.next() {
1673        if character != '~' {
1674            output.push(character);
1675            continue;
1676        }
1677        match characters.next()? {
1678            '0' => output.push('~'),
1679            '1' => output.push('/'),
1680            _ => return None,
1681        }
1682    }
1683    Some(output)
1684}
1685
1686fn field_child(parent: &str, child: &str) -> String {
1687    let child = safe_error_value(child);
1688    format!("{parent}.{child}")
1689}
1690
1691fn value_description(value: &Value) -> String {
1692    match value {
1693        Value::String(value) => safe_error_value(value),
1694        Value::Null => "null".to_string(),
1695        Value::Bool(value) => value.to_string(),
1696        Value::Number(value) => value.to_string(),
1697        Value::Array(_) => "<array>".to_string(),
1698        Value::Object(_) => "<object>".to_string(),
1699    }
1700}
1701
1702fn safe_error_value(value: &str) -> String {
1703    let lower = value.to_ascii_lowercase();
1704    if ["secret", "password", "api_key"]
1705        .iter()
1706        .any(|marker| lower.contains(marker))
1707        || lower.starts_with("sk-")
1708        || lower.starts_with("akia")
1709        || lower.starts_with("bearer ")
1710        || lower.starts_with("token=")
1711        || lower.starts_with("credential=")
1712    {
1713        "<redacted>".to_string()
1714    } else {
1715        value.to_string()
1716    }
1717}
1718
1719/// How this module was sourced, as declared by the module itself.
1720///
1721/// Not read on any daemon routing or admission path; relayed verbatim. A
1722/// module declares it because it describes the module, not because the
1723/// daemon consumes it, and leaves it absent rather than inventing a value.
1724#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1725#[serde(rename_all = "snake_case")]
1726pub enum TrustTier {
1727    FirstParty,
1728    Reviewed,
1729    Untrusted,
1730}
1731
1732/// Provider capabilities exposed by a module.
1733///
1734/// The role set is closed for protocol v1; unknown role tags fail serde decode.
1735#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1736#[serde(tag = "role", rename_all = "snake_case")]
1737pub enum ProviderRole {
1738    ToolProvider {
1739        tools: Vec<Tool>,
1740        /// Which `BindIdentity` keys PARTITION this provider's state or
1741        /// answers: a module whose reply to a call depends on the caller's
1742        /// project declares `Project`; one that threads per session declares
1743        /// `Session`; one that answers identically to every caller declares
1744        /// `[]`. It states what the module does with the keys it is handed,
1745        /// not which keys it will accept — every bind carries all of them.
1746        /// Not read on any daemon path; relayed verbatim for consumers.
1747        identity_scope: Vec<IdentityScope>,
1748        concurrency: Concurrency,
1749        emits_push: bool,
1750        sub_supervises: bool,
1751    },
1752    PipelineStage {
1753        stage: PipelineStageKind,
1754        applies_to: PipelineAppliesTo,
1755        interface: String,
1756        declares_frozen_floor: bool,
1757        needs_signals: Vec<String>,
1758        conformance_class: String,
1759    },
1760    ManagementSurface {
1761        operations: Vec<ManagementOperation>,
1762        config_schema: Value,
1763        observability: Vec<ObservabilitySurface>,
1764        /// Same meaning as on `ToolProvider`: the keys that partition this
1765        /// surface's state or answers; `[]` for a surface that serves the
1766        /// same answer to every caller.
1767        identity_scope: Vec<IdentityScope>,
1768        #[serde(default)]
1769        concurrency: Concurrency,
1770    },
1771    InternalService {
1772        service_id: String,
1773        transport: InternalTransport,
1774        agent_facing: bool,
1775        operations: Vec<String>,
1776    },
1777}
1778
1779/// How a tool's side effects are fenced for durable at-most-once handling.
1780///
1781/// Classified on a tool's externally-observable effects, never inferred from
1782/// the module's concurrency lane:
1783/// - `Pure`: no observable side effect (reads, searches, cache warming) — safe
1784///   to re-run after an indeterminate outcome.
1785/// - `Mutating`: a fenceable external side effect such as a file write — a
1786///   re-run risks a duplicate effect, so an indeterminate outcome must not
1787///   auto-retry.
1788/// - `Unfenceable`: a side effect that cannot be fenced or safely replayed,
1789///   such as running a shell command — never auto-re-run on an indeterminate
1790///   outcome.
1791#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1792#[serde(rename_all = "snake_case")]
1793pub enum ExecutionMode {
1794    Pure,
1795    Mutating,
1796    Unfenceable,
1797}
1798
1799/// Tool-plane capability exposed by a `tool_provider`.
1800#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1801pub struct Tool {
1802    pub name: String,
1803    #[serde(default, skip_serializing_if = "Option::is_none")]
1804    pub description: Option<String>,
1805    /// How the tool's side effects are fenced for durable at-most-once handling.
1806    /// Observability + durability metadata only; subc's thin core never acts on
1807    /// this for routing, scheduling, or concurrency — the module's declared
1808    /// [`Concurrency`] contract governs delivery.
1809    pub execution_mode: ExecutionMode,
1810    pub schema: Value,
1811}
1812
1813/// How subc may deliver concurrent in-flight calls to the provider.
1814///
1815/// subc records and forwards these semantics unchanged; the dispatcher that
1816/// enforces them lives in subc-core, kept separate from this frozen manifest
1817/// contract.
1818#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1819#[serde(rename_all = "snake_case")]
1820pub enum Concurrency {
1821    /// One in-flight call at a time with strict submission and response order.
1822    Serial,
1823    /// Concurrent in-flight calls may span channels, while subc preserves FIFO
1824    /// submission within each channel; the module schedules internally.
1825    ModuleManaged,
1826    /// Fully parallel delivery with no ordering guarantee across or within
1827    /// channels.
1828    StatelessParallel,
1829}
1830
1831#[allow(clippy::derivable_impls)]
1832// The default is PINNED BY HISTORY, not chosen as the best value. Before this
1833// field existed, every ManagementSurface received ModuleManaged delivery (32
1834// concurrent credits) unconditionally, so an absent-field manifest must resolve
1835// to exactly that behavior -- any other default (including the fail-closed
1836// Serial) would convert a daemon upgrade into a silent delivery-semantics
1837// change for every deployed module. A genuinely-Serial module was ALREADY
1838// receiving concurrent delivery under pre-field daemons; the field's addition
1839// is what makes declaring Serial possible at all, so the fix for such a module
1840// is an explicit declaration, and the daemon logs defaulted registrations so
1841// the fleet's exposure is readable rather than assumed.
1842impl Default for Concurrency {
1843    fn default() -> Self {
1844        Self::ModuleManaged
1845    }
1846}
1847
1848/// A `BindIdentity` key a provider partitions its state or answers by.
1849///
1850/// Declared in a role's `identity_scope` to say which caller keys change
1851/// what the module does; the daemon hands every bind all of the keys
1852/// regardless, so an empty declaration means "answers do not depend on the
1853/// caller", never "keys are refused".
1854#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1855#[serde(rename_all = "snake_case")]
1856pub enum IdentityScope {
1857    Session,
1858    Project,
1859}
1860
1861/// Proxy-plane stage kind.
1862#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1863#[serde(rename_all = "snake_case")]
1864pub enum PipelineStageKind {
1865    Transform,
1866    Codec,
1867    Auth,
1868}
1869
1870/// Provider/model selector for a pipeline stage. `"*"` denotes wildcard.
1871#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1872pub struct PipelineAppliesTo {
1873    pub provider: String,
1874    pub model: String,
1875}
1876
1877/// Operation exposed on the management plane.
1878#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1879pub struct ManagementOperation {
1880    pub name: String,
1881    pub kind: ManagementOperationKind,
1882    #[serde(default, skip_serializing_if = "Option::is_none")]
1883    pub description: Option<String>,
1884}
1885
1886#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1887#[serde(rename_all = "snake_case")]
1888pub enum ManagementOperationKind {
1889    Query,
1890    Mutate,
1891}
1892
1893/// Observable state exposed on the management plane.
1894#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1895pub struct ObservabilitySurface {
1896    pub name: String,
1897    pub kind: ObservabilityKind,
1898}
1899
1900#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1901#[serde(rename_all = "snake_case")]
1902pub enum ObservabilityKind {
1903    Snapshot,
1904    Stream,
1905}
1906
1907#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1908#[serde(rename_all = "snake_case")]
1909pub enum InternalTransport {
1910    Bulk,
1911}
1912
1913/// Consumer capabilities requested by a module.
1914#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1915#[serde(tag = "role", rename_all = "snake_case")]
1916pub enum ConsumerRole {
1917    ToolClient { of: Vec<String> },
1918    LlmClient { via: String, auth: String },
1919    ServiceClient { of: Vec<String> },
1920}
1921
1922/// External storage, vault, and identity bindings supplied through subc.
1923#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1924pub struct Bindings {
1925    pub storage: StorageBinding,
1926    pub vault_grants: Vec<VaultGrant>,
1927    pub identity: IdentityBinding,
1928}
1929
1930/// Storage backend supplied by subc; the module owns its schema.
1931#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1932pub struct StorageBinding {
1933    pub kind: StorageKind,
1934    pub scope: StorageScope,
1935    pub owns_schema: bool,
1936}
1937
1938#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1939#[serde(rename_all = "snake_case")]
1940pub enum StorageKind {
1941    Sqlite,
1942}
1943
1944#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1945#[serde(rename_all = "snake_case")]
1946pub enum StorageScope {
1947    Project,
1948}
1949
1950#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1951pub struct VaultGrant {
1952    pub secret: String,
1953    pub reason: String,
1954}
1955
1956#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1957pub struct IdentityBinding {
1958    pub requires: Vec<IdentityScope>,
1959    pub optional: Vec<IdentityScope>,
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965    use serde_json::json;
1966
1967    fn aft_manifest_fixture() -> ModuleManifest {
1968        ModuleManifest::builder("aft", "0.39.2")
1969            .trust_tier(Some(TrustTier::FirstParty))
1970            .bindings(Some(Bindings {
1971                storage: StorageBinding {
1972                    kind: StorageKind::Sqlite,
1973                    scope: StorageScope::Project,
1974                    owns_schema: true,
1975                },
1976                vault_grants: vec![VaultGrant {
1977                    secret: "provider_api_key".to_string(),
1978                    reason: "cortexkit_native auth".to_string(),
1979                }],
1980                identity: IdentityBinding {
1981                    requires: vec![IdentityScope::Project],
1982                    optional: vec![IdentityScope::Session],
1983                },
1984            }))
1985            .protocol_ver(1)
1986            .provides(vec![ProviderRole::ToolProvider {
1987                tools: vec![
1988                    Tool {
1989                        name: "read".to_string(),
1990                        description: None,
1991                        execution_mode: ExecutionMode::Pure,
1992                        schema: json!({"type": "object"}),
1993                    },
1994                    Tool {
1995                        name: "grep".to_string(),
1996                        description: None,
1997                        execution_mode: ExecutionMode::Pure,
1998                        schema: json!({"type": "object"}),
1999                    },
2000                    Tool {
2001                        name: "outline".to_string(),
2002                        description: None,
2003                        execution_mode: ExecutionMode::Pure,
2004                        schema: json!({"type": "object"}),
2005                    },
2006                    Tool {
2007                        name: "semantic_search".to_string(),
2008                        description: None,
2009                        execution_mode: ExecutionMode::Pure,
2010                        schema: json!({"type": "object"}),
2011                    },
2012                    Tool {
2013                        name: "edit".to_string(),
2014                        description: None,
2015                        execution_mode: ExecutionMode::Mutating,
2016                        schema: json!({"type": "object"}),
2017                    },
2018                    Tool {
2019                        name: "write".to_string(),
2020                        description: None,
2021                        execution_mode: ExecutionMode::Mutating,
2022                        schema: json!({"type": "object"}),
2023                    },
2024                    Tool {
2025                        name: "bash".to_string(),
2026                        description: None,
2027                        execution_mode: ExecutionMode::Unfenceable,
2028                        schema: json!({"type": "object"}),
2029                    },
2030                ],
2031                identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
2032                concurrency: Concurrency::ModuleManaged,
2033                emits_push: true,
2034                sub_supervises: true,
2035            }])
2036            .consumes(vec![ConsumerRole::ServiceClient {
2037                of: vec!["embedding.v2".to_string()],
2038            }])
2039            .build()
2040    }
2041
2042    #[test]
2043    fn serde_round_trips_representative_manifest() {
2044        let manifest = aft_manifest_fixture();
2045        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
2046        let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
2047
2048        assert_eq!(manifest, decoded);
2049    }
2050
2051    #[test]
2052    fn builder_defaults_additions_to_honest_absence_and_round_trips() {
2053        let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
2054
2055        assert_eq!(manifest.module_id, "builder-defaults");
2056        assert_eq!(manifest.module_version, "2.0.0");
2057        assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
2058        assert_eq!(manifest.trust_tier, None);
2059        assert!(manifest.provides.is_empty());
2060        assert!(manifest.consumes.is_empty());
2061        assert_eq!(manifest.bindings, None);
2062        assert_eq!(manifest.capabilities, None);
2063        assert_eq!(manifest.self_signals, None);
2064        assert_eq!(manifest.provenance, None);
2065
2066        let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
2067        for optional in [
2068            "trust_tier",
2069            "consumes",
2070            "bindings",
2071            "capabilities",
2072            "self_signals",
2073            "provenance",
2074        ] {
2075            assert!(
2076                encoded.get(optional).is_none(),
2077                "an absent {optional} declaration must stay absent on the wire"
2078            );
2079        }
2080        let decoded: ModuleManifest =
2081            serde_json::from_value(encoded).expect("builder manifest round-trips");
2082        assert_eq!(decoded, manifest);
2083    }
2084
2085    #[test]
2086    fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
2087        let manifest = ModuleManifest::builder("full-builder", "2.0.0")
2088            .trust_tier(Some(TrustTier::Reviewed))
2089            .bindings(Some(Bindings {
2090                storage: StorageBinding {
2091                    kind: StorageKind::Sqlite,
2092                    scope: StorageScope::Project,
2093                    owns_schema: false,
2094                },
2095                vault_grants: Vec::new(),
2096                identity: IdentityBinding {
2097                    requires: vec![IdentityScope::Project],
2098                    optional: Vec::new(),
2099                },
2100            }))
2101            .provides(vec![ProviderRole::ToolProvider {
2102                tools: vec![Tool {
2103                    name: "read".to_string(),
2104                    description: None,
2105                    execution_mode: ExecutionMode::Pure,
2106                    schema: json!({"type": "object"}),
2107                }],
2108                identity_scope: vec![IdentityScope::Project],
2109                concurrency: Concurrency::Serial,
2110                emits_push: false,
2111                sub_supervises: false,
2112            }])
2113            .consumes(vec![ConsumerRole::ServiceClient {
2114                of: vec!["embedding.v2".to_string()],
2115            }])
2116            .capabilities(Some(CapabilityDeclarations {
2117                provides: vec!["embedding/v2".to_string()],
2118                requires: Vec::new(),
2119                must_never_reach: Vec::new(),
2120            }))
2121            .self_signals(Some(vec![SelfSignalDeclaration {
2122                name: "usage_poller".to_string(),
2123                kind: SelfSignalKind::Poller,
2124                effect: SelfSignalEffect::Observe,
2125                anchored_to: SignalAnchor::FixedInterval,
2126                cadence: Some(SignalCadence::Literal {
2127                    interval_ms: 60_000,
2128                }),
2129                domain: Some("provider-usage".to_string()),
2130                note: None,
2131            }]))
2132            .provenance(Some(ManifestProvenance {
2133                build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2134                build_git_sha_absence_reason: None,
2135                build_lock_digest: Some(
2136                    "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2137                ),
2138                wire_crate_version: Some("0.16.0".to_string()),
2139                store_schema_version: Some("42".to_string()),
2140                launch_nonce_source: None,
2141            }))
2142            .build();
2143
2144        assert_eq!(
2145            serde_json::to_vec(&manifest).expect("builder manifest serializes"),
2146            include_bytes!("../tests/golden/module_manifest_builder_full.json"),
2147            "the builder must preserve the prior fully populated literal wire bytes"
2148        );
2149    }
2150
2151    #[test]
2152    fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
2153        let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
2154        let decoded: ModuleManifest =
2155            serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
2156
2157        assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
2158        assert!(!decoded.consumes.is_empty());
2159        assert!(decoded.bindings.is_some());
2160
2161        let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
2162        assert_eq!(
2163            reencoded, raw,
2164            "old manifest relay stays byte-for-byte verbatim"
2165        );
2166    }
2167
2168    #[test]
2169    fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
2170        let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
2171        let decoded: ModuleManifest =
2172            serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
2173
2174        assert_eq!(decoded.trust_tier, None);
2175        assert!(decoded.consumes.is_empty());
2176        assert_eq!(decoded.bindings, None);
2177
2178        let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
2179        assert_eq!(
2180            pretty.as_bytes(),
2181            raw,
2182            "new manifest matches golden byte-for-byte without unread keys"
2183        );
2184
2185        let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
2186        assert!(
2187            as_val.get("trust_tier").is_none(),
2188            "no trust_tier on wire for new manifest"
2189        );
2190        assert!(
2191            as_val.get("consumes").is_none(),
2192            "no consumes on wire for empty consumes"
2193        );
2194        assert!(
2195            as_val.get("bindings").is_none(),
2196            "no bindings on wire for new manifest"
2197        );
2198    }
2199
2200    #[test]
2201    fn aft_manifest_fixture_matches_v1_contract() {
2202        let manifest = aft_manifest_fixture();
2203
2204        assert_eq!(manifest.module_id, "aft");
2205        let ProviderRole::ToolProvider {
2206            tools,
2207            identity_scope,
2208            concurrency,
2209            emits_push,
2210            sub_supervises,
2211        } = &manifest.provides[0]
2212        else {
2213            panic!("AFT fixture must expose one tool_provider role");
2214        };
2215
2216        assert_eq!(*concurrency, Concurrency::ModuleManaged);
2217        assert!(*emits_push);
2218        assert!(*sub_supervises);
2219        assert_eq!(
2220            identity_scope,
2221            &vec![IdentityScope::Session, IdentityScope::Project]
2222        );
2223        assert_eq!(
2224            tools
2225                .iter()
2226                .map(|tool| (tool.name.as_str(), tool.execution_mode))
2227                .collect::<Vec<_>>(),
2228            vec![
2229                ("read", ExecutionMode::Pure),
2230                ("grep", ExecutionMode::Pure),
2231                ("outline", ExecutionMode::Pure),
2232                ("semantic_search", ExecutionMode::Pure),
2233                ("edit", ExecutionMode::Mutating),
2234                ("write", ExecutionMode::Mutating),
2235                ("bash", ExecutionMode::Unfenceable),
2236            ]
2237        );
2238    }
2239
2240    #[test]
2241    fn tool_provider_role_tag_serializes_as_snake_case() {
2242        let manifest = aft_manifest_fixture();
2243        let value = serde_json::to_value(&manifest).unwrap();
2244
2245        assert_eq!(value["provides"][0]["role"], "tool_provider");
2246    }
2247
2248    #[test]
2249    fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
2250        let manifest = aft_manifest_fixture();
2251        let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
2252        assert!(encoded.get("capabilities").is_none());
2253
2254        let decoded: ModuleManifest =
2255            serde_json::from_value(encoded).expect("legacy manifest parses");
2256        assert_eq!(decoded.capabilities, None);
2257    }
2258
2259    #[test]
2260    fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
2261        for identifier in [
2262            "a/v1",
2263            "credentials-provider/v1",
2264            "a1-b2/v4294967295",
2265            "a123456789012345678901234567890123456789012345678901234567890123/v1",
2266        ] {
2267            assert!(
2268                is_valid_capability_identifier(identifier),
2269                "identifier must be accepted: {identifier}"
2270            );
2271        }
2272
2273        for identifier in [
2274            "credentials-Provider/v1",
2275            "credentials-provider/v01",
2276            "credentials-provider-/v1",
2277            "credentials--provider/v1",
2278            "Credentials-provider/v1",
2279            "credentials-provider/1",
2280            "credentials provider/v1",
2281            "credentials-provider/v0",
2282            "credentials-provider/v4294967296",
2283            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
2284        ] {
2285            assert!(
2286                !is_valid_capability_identifier(identifier),
2287                "identifier must be rejected: {identifier}"
2288            );
2289        }
2290    }
2291
2292    #[test]
2293    fn capability_grammar_errors_redact_secret_shaped_values() {
2294        let error = validate_manifest_capability_grammar(&json!({
2295            "capabilities": { "provides": ["sk-secret-value/v0"] }
2296        }))
2297        .expect_err("secret-shaped capability identifier is malformed");
2298        assert_eq!(error.field(), "capabilities.provides[0]");
2299        assert_eq!(error.value(), "<redacted>");
2300        assert!(!error.to_string().contains("sk-secret-value"));
2301    }
2302
2303    /// Builder sentinels are the strings tooling emits where it means "no
2304    /// value" (shell fallbacks say `unknown`, not `unavailable`); publishing
2305    /// one as a build fact is the well-formed lie the provenance contract
2306    /// names. The helper must map every sentinel, any casing, to field
2307    /// omission — and must keep a canonical real value intact (the control arm,
2308    /// so the filter cannot pass by refusing everything).
2309    #[test]
2310    fn provenance_builder_sentinels_become_field_omission() {
2311        for sentinel in [
2312            "unknown",
2313            "UNKNOWN",
2314            "Unknown",
2315            "unavailable",
2316            "none",
2317            "None",
2318            "  unknown  ",
2319            "",
2320        ] {
2321            let p = build_provenance_from_source(
2322                BuildGitShaSource::Git {
2323                    revision: sentinel,
2324                    tree_state: GitTreeState::Clean,
2325                },
2326                Some(sentinel),
2327                Some(sentinel),
2328            )
2329            .expect("sentinels are omitted before form validation");
2330            assert_eq!(
2331                (
2332                    p.build_git_sha,
2333                    p.build_git_sha_absence_reason,
2334                    p.build_lock_digest,
2335                    p.store_schema_version,
2336                ),
2337                (
2338                    None,
2339                    Some(BuildGitShaAbsenceReason::NeverDerived),
2340                    None,
2341                    None,
2342                ),
2343                "sentinel {sentinel:?} must be omitted, not published"
2344            );
2345        }
2346        let real = build_provenance_from_source(
2347            BuildGitShaSource::Git {
2348                revision: "0123456789abcdef0123456789abcdef01234567",
2349                tree_state: GitTreeState::Clean,
2350            },
2351            None,
2352            Some("9"),
2353        )
2354        .expect("canonical build revision is accepted");
2355        assert_eq!(
2356            real.build_git_sha.as_deref(),
2357            Some("0123456789abcdef0123456789abcdef01234567")
2358        );
2359        assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2360        // The always-knowable fact: an SDK-built block always carries a crate
2361        // version, so it is never empty; that is why the contract omits a
2362        // field when it is absent rather than publishing a sentinel.
2363        assert_eq!(
2364            real.wire_crate_version.as_deref(),
2365            Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2366        );
2367    }
2368
2369    #[test]
2370    fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2371        let provenance = build_provenance_from_source(
2372            BuildGitShaSource::Git {
2373                revision: " 0123456789abcdef0123456789abcdef01234567 ",
2374                tree_state: GitTreeState::Clean,
2375            },
2376            Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2377            Some(" schema-v3 "),
2378        )
2379        .expect("canonical build facts are accepted");
2380
2381        assert_eq!(
2382            provenance,
2383            ManifestProvenance {
2384                build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2385                build_git_sha_absence_reason: None,
2386                build_lock_digest: Some(
2387                    "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2388                ),
2389                wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2390                store_schema_version: Some("schema-v3".to_string()),
2391                launch_nonce_source: None,
2392            }
2393        );
2394    }
2395
2396    #[test]
2397    fn build_provenance_refuses_an_abbreviated_git_sha() {
2398        let error = build_provenance_from_source(
2399            BuildGitShaSource::Git {
2400                revision: "0123456789ab",
2401                tree_state: GitTreeState::Clean,
2402            },
2403            None,
2404            None,
2405        )
2406        .expect_err("a 12-character abbreviation is not canonical");
2407
2408        assert_eq!(error.field(), "build_git_sha");
2409        assert_eq!(error.length(), 12);
2410        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2411        assert_eq!(
2412            error.to_string(),
2413            "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2414        );
2415    }
2416
2417    #[test]
2418    fn build_provenance_refuses_an_abbreviated_lock_digest() {
2419        let error = build_provenance_from_source(
2420            BuildGitShaSource::NeverDerived,
2421            Some("0123456789abcdef"),
2422            None,
2423        )
2424        .expect_err("a 16-character digest is not canonical");
2425
2426        assert_eq!(error.field(), "build_lock_digest");
2427        assert_eq!(error.length(), 16);
2428        assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2429    }
2430
2431    #[test]
2432    fn build_provenance_refuses_uppercase_hex() {
2433        let uppercase_sha = "A".repeat(40);
2434        let error = build_provenance_from_source(
2435            BuildGitShaSource::Git {
2436                revision: &uppercase_sha,
2437                tree_state: GitTreeState::Clean,
2438            },
2439            None,
2440            None,
2441        )
2442        .expect_err("uppercase hexadecimal is not canonical");
2443
2444        assert_eq!(error.field(), "build_git_sha");
2445        assert_eq!(error.length(), 40);
2446        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2447    }
2448
2449    #[test]
2450    fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2451        let error = build_provenance_from_source(
2452            BuildGitShaSource::Git {
2453                revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2454                tree_state: GitTreeState::Clean,
2455            },
2456            None,
2457            None,
2458        )
2459        .expect_err("a dirty stamp is not a canonical build revision");
2460
2461        assert_eq!(error.field(), "build_git_sha");
2462        assert_eq!(error.length(), 46);
2463        assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2464    }
2465
2466    #[test]
2467    fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2468        let provenance = build_provenance_from_source(
2469            BuildGitShaSource::Git {
2470                revision: "unavailable",
2471                tree_state: GitTreeState::Clean,
2472            },
2473            Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2474            None,
2475        )
2476        .expect("sentinel SHA is omitted before the valid lock digest is checked");
2477
2478        assert_eq!(provenance.build_git_sha, None);
2479        assert_eq!(
2480            provenance.build_lock_digest,
2481            Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2482        );
2483        assert_eq!(
2484            provenance.wire_crate_version,
2485            Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2486        );
2487    }
2488
2489    #[test]
2490    fn build_provenance_omits_fully_unavailable_inputs() {
2491        let provenance = build_provenance_from_source(
2492            BuildGitShaSource::NeverDerived,
2493            Some(" unavailable "),
2494            Some("   "),
2495        )
2496        .expect("omitted and sentinel inputs are not form errors");
2497
2498        assert_eq!(provenance.build_git_sha, None);
2499        assert_eq!(provenance.build_lock_digest, None);
2500        assert_eq!(provenance.store_schema_version, None);
2501        assert_eq!(
2502            provenance.wire_crate_version,
2503            Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2504        );
2505    }
2506
2507    #[test]
2508    fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2509        let revision = "0123456789abcdef0123456789abcdef01234567";
2510        for (input, expected) in [
2511            (
2512                Some(revision),
2513                format!(
2514                    r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2515                    crate::SUBC_PROTOCOL_CRATE_VERSION
2516                ),
2517            ),
2518            (
2519                None,
2520                format!(
2521                    r#"{{"wire_crate_version":"{}"}}"#,
2522                    crate::SUBC_PROTOCOL_CRATE_VERSION
2523                ),
2524            ),
2525            (
2526                Some("unknown"),
2527                format!(
2528                    r#"{{"wire_crate_version":"{}"}}"#,
2529                    crate::SUBC_PROTOCOL_CRATE_VERSION
2530                ),
2531            ),
2532        ] {
2533            let provenance = build_provenance(input, None, None)
2534                .expect("the legacy build facts remain constructible");
2535            assert_eq!(provenance.build_git_sha_absence_reason, None);
2536            assert_eq!(
2537                serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2538                expected
2539            );
2540        }
2541    }
2542
2543    #[test]
2544    fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2545        let revision = "0123456789abcdef0123456789abcdef01234567";
2546        let cases = [
2547            (
2548                BuildGitShaSource::Git {
2549                    revision,
2550                    tree_state: GitTreeState::Clean,
2551                },
2552                Some(revision),
2553                None,
2554            ),
2555            (
2556                BuildGitShaSource::Git {
2557                    revision,
2558                    tree_state: GitTreeState::Dirty,
2559                },
2560                None,
2561                Some(BuildGitShaAbsenceReason::DeclinedDirty),
2562            ),
2563            (
2564                BuildGitShaSource::NeverDerived,
2565                None,
2566                Some(BuildGitShaAbsenceReason::NeverDerived),
2567            ),
2568            (
2569                BuildGitShaSource::NoGitDir,
2570                None,
2571                Some(BuildGitShaAbsenceReason::NoGitDir),
2572            ),
2573        ];
2574
2575        for (source, expected_sha, expected_reason) in cases {
2576            let provenance = build_provenance_from_source(source, None, None)
2577                .expect("every stamping state constructs honest provenance");
2578            assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2579            assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2580        }
2581    }
2582
2583    #[test]
2584    fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2585        let wire = format!(
2586            r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2587            crate::SUBC_PROTOCOL_CRATE_VERSION
2588        );
2589        let provenance: ManifestProvenance =
2590            serde_json::from_str(&wire).expect("future absence reasons remain readable");
2591
2592        assert_eq!(
2593            provenance.build_git_sha_absence_reason,
2594            Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2595                "future_stamper_state".to_string()
2596            ))
2597        );
2598        assert_eq!(
2599            serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2600            wire
2601        );
2602    }
2603
2604    #[test]
2605    fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2606        let error = serde_json::from_value::<ManifestProvenance>(json!({
2607            "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2608            "build_git_sha_absence_reason": "declined_dirty"
2609        }))
2610        .expect_err("a declared commit cannot also claim an absence reason");
2611
2612        assert!(error.to_string().contains(
2613            "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2614        ));
2615    }
2616}