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