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