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