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