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