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