Skip to main content

subc_protocol/
manifest.rs

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