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