Skip to main content

memstead_base/
binding.rs

1//! Binding format **v1** — the additive format foundation for the projection
2//! promotion (bundle plan `03-projection-promotion`, decisions D1/D5/D6).
3//!
4//! This is the **live** binding shape: [`crate::pipeline_store::load_pipeline_configs`]
5//! reads it (version-gated), the `projection` CLI tree writes it, and the
6//! resolve / brief / status / advance paths consume it. The legacy
7//! four-primitive `Projection` + flat-ingest store is parsed only by the
8//! migrate/legacy path (via [`crate::pipeline_store::LegacyIngest`]); the
9//! retired `Ingest` / `IngestMode` machinery is gone.
10//!
11//! Three things live here:
12//!
13//! 1. [`BindingV1`] — the versioned binding record (D1): one file per
14//!    source→mem obligation, collapsing the projection + ingest split into a
15//!    single record with an `operations { build, sync, verify }` block.
16//! 2. [`hash_binding`] — `hash(D)` (D5): the lowercase-hex SHA-256 of the
17//!    canonical JSON of a binding's *content-defining resolved projection*.
18//!    Scheduling knobs (`trigger` / `batch_size` / `post_actions`) are
19//!    excluded by construction; a facet selection pattern or a medium pointer
20//!    changing — inputs *outside* the binding file — changes the hash.
21//! 3. [`medium_capabilities`] + [`validate_binding`] — the medium-capability
22//!    matrix (D6) and the validation entry point that generalizes the
23//!    render-time preparation refusal to binding-validation time.
24//!
25//! The findings-store key + record (plan 03's schema stub, once here) now live
26//! as the real, IO-backed store in [`crate::ingest::findings`] (group A of plan
27//! 05): [`crate::ingest::findings::FindingKey`] keys it, `hash(D)` still
28//! partitions its keyspace so a declaration edit invalidates prior findings.
29
30use serde::{Deserialize, Serialize};
31use sha2::{Digest as _, Sha256};
32
33use crate::ingest::resolve::ResolvedPrimarySource;
34use crate::pipeline::{IngestTrigger, MediumType, PatternEntry};
35
36/// The current binding format version. A v1 binding carries `version: 1`.
37pub const BINDING_VERSION: u32 = 1;
38
39/// The engine's current preparation-implementation version — the single
40/// source of truth for "which preparation implementation is live".
41///
42/// No preparation implementation exists yet, so this is `0` ("none"). It
43/// nonetheless participates in [`hash_binding`]: a future preparation
44/// implementation bumps this constant, which — because the preparation
45/// identifier + this version are both hashed — invalidates every prior
46/// finding keyed on the old `hash(D)` by construction.
47pub const PREPARATION_IMPL_VERSION: u32 = 0;
48
49// ---------------------------------------------------------------------------
50// D1 — Binding format v1
51// ---------------------------------------------------------------------------
52
53/// Coverage semantics — whether the binding claims to cover *everything* in
54/// its declared scope (`exhaustive`) or a deliberately partial slice
55/// (`curated`). Defaults to [`CoverageSemantics::Exhaustive`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
57#[serde(rename_all = "lowercase")]
58pub enum CoverageSemantics {
59    /// Every artifact in scope is expected to be accounted for.
60    #[default]
61    Exhaustive,
62    /// A deliberately partial selection — an unaccounted artifact is
63    /// information, not a defect.
64    Curated,
65}
66
67/// How a [`BuildOperation`] engages its binding. **`refinement` is deleted
68/// from the vocabulary** (D1) — it is neither a variant here nor migrated, so
69/// deserializing `"mode": "refinement"` fails as an unknown value.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum BuildMode {
73    /// Build out new coverage.
74    Discovery,
75    /// A single bounded pass.
76    OneShot,
77}
78
79/// The **build** operation — the only operation carrying a mode. Grows new
80/// coverage (or runs a one-shot lens). `trigger` / `batch_size` /
81/// `post_actions` are scheduling attributes, excluded from [`hash_binding`].
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct BuildOperation {
84    /// Discovery / one-shot. The one operation with a mode.
85    pub mode: BuildMode,
86    /// What sets this operation running (loop / manual / on-event).
87    pub trigger: IngestTrigger,
88    /// How many artifacts a single run processes.
89    pub batch_size: u32,
90    /// Free-form post-run actions (e.g. a one-shot `archive_source` flag).
91    /// Opaque to the engine — consumed only by the one-shot brief renderer.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub post_actions: Option<serde_json::Value>,
94}
95
96/// The **sync** operation — the (future) sole maintenance writer. Optional: an
97/// absent `sync` block makes that *mutating* operation refuse at run time.
98/// Carries no mode.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SyncOperation {
101    /// What sets a sync running.
102    pub trigger: IngestTrigger,
103    /// How many artifacts a single run processes.
104    pub batch_size: u32,
105}
106
107/// Default per-run tier-3 adjudication cap (bundle plan `05-verify-sync-engine`,
108/// D1/D4). Dogfood-tuned against the live `engine/graph` binding (524 source
109/// artifacts): a fully-drifted mem of that scale clears its adjudication backlog
110/// in ~11 verify runs while each run's asserted-drift work stays bounded and its
111/// token cost predictable. `0` disables the cap (adjudicate every candidate).
112pub const DEFAULT_ADJUDICATION_CAP: u32 = 50;
113
114/// Default `full_resync_every` (bundle plan `05-verify-sync-engine`, D3/D4):
115/// fire a guaranteed full-enumeration coverage sweep every N verify runs.
116/// Dogfood-tuned against `engine/graph` (524 artifacts, sample batch 20 → a
117/// rotation completes in ~27 runs): a sweep every 20 runs guarantees a complete
118/// coverage picture without waiting on the rotation to happen to finish. `0`
119/// disables scheduled full walks (rotating sample only).
120pub const DEFAULT_FULL_RESYNC_EVERY: u32 = 20;
121
122fn default_adjudication_cap() -> u32 {
123    DEFAULT_ADJUDICATION_CAP
124}
125
126fn default_full_resync_every() -> u32 {
127    DEFAULT_FULL_RESYNC_EVERY
128}
129
130/// The **verify** operation — read-only measurement. Optional: an absent
131/// `verify` block means engine defaults, never a refusal (verify is
132/// read-only). Carries no mode.
133///
134/// `adjudication_cap` and `full_resync_every` are the tier-3 operations knobs
135/// (bundle plan `05-verify-sync-engine`, group D): scheduling attributes on the
136/// measurement side only — like `trigger` / `batch_size`, they never change what
137/// the mem claims, so they are excluded from [`hash_binding`] (the whole
138/// `verify` block is). Both are additive: an older `verify` block without them
139/// deserializes to the dogfood-tuned defaults.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct VerifyOperation {
142    /// What sets a verify running.
143    pub trigger: IngestTrigger,
144    /// How many artifacts a single run processes.
145    pub batch_size: u32,
146    /// Per-run tier-3 adjudication cap (D1): the maximum number of hash-drift
147    /// adjudications a single verify run asserts. Once the cap is reached the
148    /// run **stops adjudicating** and queues the remaining drift candidates as
149    /// `queued-for-adjudication` findings (the tier-3 backlog the fidelity
150    /// report renders). Combined with the rotating sample (D2), successive runs
151    /// adjudicate different windows, so the whole anchor set is covered over a
152    /// full rotation. `0` disables the cap. Defaults to
153    /// [`DEFAULT_ADJUDICATION_CAP`].
154    #[serde(default = "default_adjudication_cap")]
155    pub adjudication_cap: u32,
156    /// Scheduled full-enumeration walk cadence (D3): every N verify runs, a full
157    /// coverage sweep enumerates the whole source set (`S(D)`) for **enumerable**
158    /// mediums, guaranteeing eventual complete coverage rather than relying on
159    /// the rotating sample to finish. For a medium the capability matrix marks
160    /// **non-enumerable**, the scheduled walk refuses with a typed signal — never
161    /// a silent skip, never a fabricated full-coverage claim. `0` disables
162    /// scheduled full walks. Defaults to [`DEFAULT_FULL_RESYNC_EVERY`].
163    #[serde(default = "default_full_resync_every")]
164    pub full_resync_every: u32,
165}
166
167/// The prune guarantee a binding **requests** (bundle plan
168/// `05-verify-sync-engine`, F1). Prune produces deletion **proposals** surfaced
169/// in the sync brief (it never mutates the mem); the guarantee governs how a
170/// prune proposal treats a model-side edit that races a source removal.
171///
172/// The guarantee a medium can *support* is derived from its base-leg
173/// retrievability ([`prune_guarantee_for_medium`]): a git-backed source can
174/// retrieve the base leg for a real three-way merge ([`Self::NeverClobber`]);
175/// everything else degrades to conflict-flagging ([`Self::ConflictFlag`]).
176/// Requesting a guarantee the medium cannot support is refused at
177/// **binding-validation** time (never at run time) via
178/// [`CapabilityError::PruneGuaranteeUnsupported`].
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
180#[serde(rename_all = "kebab-case")]
181pub enum PruneGuarantee {
182    /// Full never-clobber three-way merge — only where the source **base leg is
183    /// retrievable** (git-backed sources). The retrieved base lets the merge
184    /// tell a model-side edit apart from a clean removal, so a divergence is
185    /// never silently proposed as a clean delete.
186    NeverClobber,
187    /// Conflict-flag degradation (the default — always supportable): where the
188    /// base leg is **not** retrievable, prune presents **both** sides and never
189    /// auto-writes over a model-side edit. The decided posture for non-git
190    /// sources (span-snapshot base legs are out of scope — no current payer).
191    #[default]
192    ConflictFlag,
193}
194
195impl PruneGuarantee {
196    /// Stable wire form.
197    pub fn as_wire(&self) -> &'static str {
198        match self {
199            PruneGuarantee::NeverClobber => "never-clobber",
200            PruneGuarantee::ConflictFlag => "conflict-flag",
201        }
202    }
203}
204
205/// The **prune** configuration of a [`BindingV1`] (F1) — additive, optional. An
206/// absent `prune` block means prune is not enabled for the binding (no deletion
207/// proposals are produced). Prune has no independent schedule: it rides the sync
208/// brief (the sole maintenance-writer channel), so it carries no `trigger` /
209/// `batch_size` — only the requested [`PruneGuarantee`]. Like the `sync` /
210/// `verify` blocks it is **excluded from [`hash_binding`]**: a maintenance
211/// policy never changes what the mem claims.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct PruneConfig {
214    /// The guarantee level the binding requests. Validated against the medium's
215    /// base-leg retrievability at binding-validation time (F1 refusal).
216    /// Defaults to [`PruneGuarantee::ConflictFlag`] when absent.
217    #[serde(default)]
218    pub guarantee: PruneGuarantee,
219}
220
221/// The operations block of a [`BindingV1`]: every operation is **optional**
222/// (D1/D6). An absent `build` / `sync` block makes that *mutating* operation
223/// refuse at run time with a `projection enable <op>` remedy; an absent
224/// `verify` block means engine defaults (verify is read-only — never a
225/// refusal). `build` is optional in serde so an absent block yields the
226/// remedy-bearing refusal rather than a generic "missing field" parse error.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct Operations {
229    /// The build operation (optional — absent = mutating op refuses with the
230    /// `projection enable build` remedy at run time).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub build: Option<BuildOperation>,
233    /// The sync operation (optional — absent = mutating op refuses with the
234    /// `projection enable sync` remedy at run time).
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub sync: Option<SyncOperation>,
237    /// The verify operation (optional — absent = engine defaults, never a refusal).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub verify: Option<VerifyOperation>,
240}
241
242/// A **binding**, format version 1 (D1). One versioned record per source→mem
243/// obligation: the projection declaration (`intent`, `source_facets`,
244/// `reference_mems`, `destination_mem`, `deny_paths`, `coverage_semantics`,
245/// `rules`) plus an `operations { build, sync, verify }` block. Collapses the
246/// legacy projection + flat-ingest split into one record.
247///
248/// This is the live store record — [`crate::pipeline_store::load_pipeline_configs`]
249/// reads it version-gated and the `projection` CLI tree writes it.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct BindingV1 {
252    /// Format version — required. v1 is [`BINDING_VERSION`]. A projection file
253    /// without it is refused by the loader (integration deferred).
254    pub version: u32,
255    /// What the binding is trying to accomplish — prose for the agent.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub intent: Option<String>,
258    /// Source facets (by name) the binding consumes.
259    #[serde(default)]
260    pub source_facets: Vec<String>,
261    /// Read-only reference mems that supply cross-mem context.
262    #[serde(default)]
263    pub reference_mems: Vec<String>,
264    /// The mem this binding writes into.
265    pub destination_mem: String,
266    /// Paths excluded from the binding's scope (workspace-relative globs).
267    /// Moved **up** from the per-ingest record — strategy-invariant.
268    #[serde(default)]
269    pub deny_paths: Vec<String>,
270    /// Whether the binding claims exhaustive or curated coverage.
271    #[serde(default)]
272    pub coverage_semantics: CoverageSemantics,
273    /// Free-form binding rules (e.g. a one-shot lens `routing` string).
274    /// Opaque to the engine — consumed only by the one-shot brief renderer.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub rules: Option<serde_json::Value>,
277    /// The **prune** policy (bundle plan `05-verify-sync-engine`, F1) — additive,
278    /// optional. Absent = prune disabled (no deletion proposals). Present = prune
279    /// produces deletion proposals in the sync brief under the requested
280    /// [`PruneGuarantee`], validated against the medium's base-leg
281    /// retrievability at binding-validation time. Excluded from [`hash_binding`]
282    /// (a maintenance policy, not content-defining).
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub prune: Option<PruneConfig>,
285    /// The operations this binding declares (build required; sync/verify optional).
286    pub operations: Operations,
287}
288
289// ---------------------------------------------------------------------------
290// D5 — hash(D)
291// ---------------------------------------------------------------------------
292
293/// A binding joined to its **resolved** primary sources — the shape
294/// [`hash_binding`] and [`validate_binding`] consume. `reference_mems` are
295/// carried on the [`BindingV1`] itself; only the primary facets need
296/// resolving (each facet's selection patterns, preparation, and its medium's
297/// type / pointer / change-detection).
298///
299/// This mirrors the resolution [`crate::ingest::resolve`] performs for the
300/// legacy ingest, reusing [`ResolvedPrimarySource`], but is constructed
301/// independently for these additive primitives — it is not produced by the
302/// live resolve path yet.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct ResolvedBinding {
305    /// The binding declaration.
306    pub binding: BindingV1,
307    /// The binding's primary sources, resolved (facet + medium), in
308    /// `source_facets` order.
309    pub primary_sources: Vec<ResolvedPrimarySource>,
310}
311
312/// One resolved facet's content-defining projection, in a fixed serde shape so
313/// [`hash_binding`] hashes every content input (D5). Private — the hash is the
314/// only consumer.
315#[derive(Serialize)]
316struct HashFacet<'a> {
317    facet: &'a str,
318    patterns: &'a [PatternEntry],
319    preparation: &'a Option<String>,
320    preparation_impl_version: u32,
321    medium_type: MediumType,
322    medium_pointer: &'a str,
323    change_detection: &'a Option<String>,
324}
325
326/// The content-defining projection of a binding, in a fixed serde shape.
327/// Private — serialized to canonical JSON for hashing. Excludes `trigger`,
328/// `batch_size`, `post_actions`, and the `sync` / `verify` blocks: scheduling
329/// never changes what the mem claims.
330#[derive(Serialize)]
331struct HashInput<'a> {
332    version: u32,
333    intent: &'a Option<String>,
334    source_facets: Vec<HashFacet<'a>>,
335    reference_mems: &'a [String],
336    destination_mem: &'a str,
337    deny_paths: &'a [String],
338    coverage_semantics: CoverageSemantics,
339    rules: &'a Option<serde_json::Value>,
340    /// The build mode participates in `hash(D)`; an absent build block simply
341    /// does not contribute it (skipped from the canonical JSON).
342    #[serde(skip_serializing_if = "Option::is_none")]
343    build_mode: Option<BuildMode>,
344}
345
346/// Serialize a JSON value with **recursively sorted object keys** and no
347/// insignificant whitespace — the canonical form. serde_json's map is a
348/// sorted `BTreeMap` today; this rebuild makes the canonicalization explicit
349/// and robust even if the `preserve_order` feature is ever enabled build-wide.
350fn canonical_json(value: &serde_json::Value) -> String {
351    fn sorted(v: &serde_json::Value) -> serde_json::Value {
352        match v {
353            serde_json::Value::Object(map) => {
354                let mut keys: Vec<&String> = map.keys().collect();
355                keys.sort();
356                let mut out = serde_json::Map::new();
357                for k in keys {
358                    out.insert(k.clone(), sorted(&map[k]));
359                }
360                serde_json::Value::Object(out)
361            }
362            serde_json::Value::Array(items) => {
363                serde_json::Value::Array(items.iter().map(sorted).collect())
364            }
365            other => other.clone(),
366        }
367    }
368    serde_json::to_string(&sorted(value)).expect("canonical JSON serializes")
369}
370
371/// Compute `hash(D)` (D5) — the lowercase-hex SHA-256 of the canonical JSON of
372/// a binding's content-defining resolved projection.
373///
374/// Hashed: `version`, `intent`, `source_facets` **resolved** (per facet: its
375/// selection patterns, its preparation identifier + [`PREPARATION_IMPL_VERSION`],
376/// and its medium's `type` / `pointer` / `change_detection`), `reference_mems`,
377/// `destination_mem`, `deny_paths`, `coverage_semantics`, `rules`, and
378/// `operations.build.mode`.
379///
380/// **Excluded:** `trigger`, `batch_size`, `post_actions`, and future tier
381/// knobs — scheduling never changes what the mem claims. Because facet
382/// selection and medium pointer participate (inputs *outside* the binding
383/// file), a change to either invalidates the hash, and thus any findings
384/// keyed on it.
385pub fn hash_binding(resolved: &ResolvedBinding) -> String {
386    let source_facets: Vec<HashFacet<'_>> = resolved
387        .primary_sources
388        .iter()
389        .map(|p| HashFacet {
390            facet: &p.facet_ref,
391            patterns: &p.scope,
392            preparation: &p.preparation,
393            preparation_impl_version: PREPARATION_IMPL_VERSION,
394            medium_type: p.medium_type,
395            medium_pointer: &p.medium_pointer,
396            change_detection: &p.declared_change_detection,
397        })
398        .collect();
399
400    let input = HashInput {
401        version: resolved.binding.version,
402        intent: &resolved.binding.intent,
403        source_facets,
404        reference_mems: &resolved.binding.reference_mems,
405        destination_mem: &resolved.binding.destination_mem,
406        deny_paths: &resolved.binding.deny_paths,
407        coverage_semantics: resolved.binding.coverage_semantics,
408        rules: &resolved.binding.rules,
409        build_mode: resolved.binding.operations.build.as_ref().map(|b| b.mode),
410    };
411
412    let value = serde_json::to_value(&input).expect("hash input serializes to a JSON value");
413    let canonical = canonical_json(&value);
414    let digest = Sha256::digest(canonical.as_bytes());
415    format!("{digest:x}")
416}
417
418// ---------------------------------------------------------------------------
419// D6 — medium-capability matrix + validation
420// ---------------------------------------------------------------------------
421
422/// What a medium can support (D6) — the row of the capability matrix for a
423/// [`MediumType`]. Pure data; [`validate_binding`] reads it to refuse
424/// operations a medium cannot support.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub struct MediumCapabilities {
427    /// Can the medium's scope be enumerated (`S(D)` computable)?
428    pub enumerable: bool,
429    /// Does the medium provide a change signal?
430    pub change_signal: bool,
431    /// Can a base version be retrieved (for three-way merge)?
432    pub base_version_retrievable: bool,
433    /// The medium's anchor namespace (`path`, `path+commit`, `entity`, `url`).
434    pub anchor_namespace: &'static str,
435    /// Is a glob `deny_paths` list legal (i.e. is the namespace path-shaped)?
436    pub glob_deny_legal: bool,
437}
438
439/// The capability-matrix row for a medium type (D6). The single source of
440/// truth the fidelity report (E3b) will also render.
441pub fn medium_capabilities(medium_type: MediumType) -> MediumCapabilities {
442    match medium_type {
443        MediumType::Codebase => MediumCapabilities {
444            enumerable: true,
445            change_signal: true,
446            base_version_retrievable: true,
447            anchor_namespace: "path",
448            glob_deny_legal: true,
449        },
450        MediumType::Filesystem => MediumCapabilities {
451            enumerable: true,
452            change_signal: true,
453            base_version_retrievable: true,
454            anchor_namespace: "path",
455            glob_deny_legal: true,
456        },
457        MediumType::Git => MediumCapabilities {
458            enumerable: true,
459            change_signal: true,
460            base_version_retrievable: true,
461            anchor_namespace: "path+commit",
462            glob_deny_legal: true,
463        },
464        MediumType::Graph => MediumCapabilities {
465            enumerable: true,
466            change_signal: true,
467            base_version_retrievable: true,
468            anchor_namespace: "entity",
469            glob_deny_legal: false,
470        },
471        MediumType::Web => MediumCapabilities {
472            // Web enumeration / change detection / base retrieval are all
473            // deferred this cycle (operator decision 7).
474            enumerable: false,
475            change_signal: false,
476            base_version_retrievable: false,
477            anchor_namespace: "url",
478            glob_deny_legal: false,
479        },
480    }
481}
482
483/// The strongest prune guarantee a medium can **support** (F1), derived from
484/// the capability matrix: a base-leg-retrievable medium (git-backed —
485/// codebase / filesystem / git / graph) supports the full never-clobber
486/// three-way merge; a non-retrievable medium (`web`) supports only conflict-flag
487/// degradation. Validation refuses a request that exceeds this.
488pub fn prune_guarantee_for_medium(medium_type: MediumType) -> PruneGuarantee {
489    if medium_capabilities(medium_type).base_version_retrievable {
490        PruneGuarantee::NeverClobber
491    } else {
492        PruneGuarantee::ConflictFlag
493    }
494}
495
496/// A binding operation subject to capability validation.
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum Operation {
499    /// The sync (maintenance-write) operation.
500    Sync,
501    /// The verify (measurement) operation.
502    Verify,
503}
504
505impl Operation {
506    /// The lowercase name used in refusal messages.
507    fn name(self) -> &'static str {
508        match self {
509            Operation::Sync => "sync",
510            Operation::Verify => "verify",
511        }
512    }
513}
514
515/// A validation-time capability refusal (D6). Sibling to
516/// [`crate::ingest::resolve::ResolveError`] (which refuses *dangling*
517/// references); this refuses declared operations a medium cannot support.
518/// Every refusal names the offending facet/medium so it is diagnosable
519/// without re-reading the store.
520#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
521pub enum CapabilityError {
522    /// A `sync` / `verify` operation is declared over a medium that cannot
523    /// support it this cycle (a `web` medium — operator decision 7). The
524    /// out-of-scope statement is said out loud, never a silent mtime-over-URL.
525    #[error(
526        "operation '{operation}' is out of scope for facet '{facet}' over a '{medium_type}' \
527         medium: this medium has no change signal this cycle (deferred — operator decision 7)"
528    )]
529    OperationOutOfScope {
530        /// The offending operation.
531        operation: &'static str,
532        /// The source facet.
533        facet: String,
534        /// The medium type that cannot support the operation.
535        medium_type: String,
536    },
537    /// Glob `deny_paths` are declared over a medium whose namespace is not
538    /// path-shaped (`graph`, `web`) — a glob cannot select in that namespace.
539    #[error(
540        "glob deny_paths are illegal for facet '{facet}' over a '{medium_type}' medium: its \
541         '{anchor_namespace}' namespace is not path-shaped"
542    )]
543    GlobDenyIllegal {
544        /// The source facet.
545        facet: String,
546        /// The medium type whose namespace is not path-shaped.
547        medium_type: String,
548        /// That medium's anchor namespace.
549        anchor_namespace: &'static str,
550    },
551    /// A facet declares a deterministic preparation step. No preparation
552    /// implementation exists ([`PREPARATION_IMPL_VERSION`] is `0`), so any
553    /// declared preparation is unsupported — refused at validation time, not
554    /// only at render time.
555    #[error(
556        "facet '{facet}' declares preparation '{preparation}', which has no implementation \
557         (preparation impl version {impl_version})"
558    )]
559    PreparationUnsupported {
560        /// The source facet.
561        facet: String,
562        /// The declared preparation identifier.
563        preparation: String,
564        /// The current preparation-implementation version (`0` = none).
565        impl_version: u32,
566    },
567    /// The binding requests a `prune` guarantee the facet's medium cannot
568    /// support (F1) — `never-clobber` over a medium whose base leg is not
569    /// retrievable (`web`). Refused at binding-validation time with the
570    /// downgrade remedy, never discovered at run time.
571    #[error(
572        "prune guarantee '{requested}' is unsupported for facet '{facet}' over a \
573         '{medium_type}' medium: its base leg is not retrievable, so only '{supported}' \
574         degradation is possible — set the binding's prune guarantee to '{supported}', or \
575         point the facet at a git-backed medium"
576    )]
577    PruneGuaranteeUnsupported {
578        /// The source facet.
579        facet: String,
580        /// The medium type that cannot support the requested guarantee.
581        medium_type: String,
582        /// The requested guarantee wire string.
583        requested: &'static str,
584        /// The strongest guarantee this medium supports (the downgrade remedy).
585        supported: &'static str,
586    },
587}
588
589/// Validate a resolved binding against the medium-capability matrix (D6),
590/// returning **every** capability refusal (empty `Err` never returned — `Ok`
591/// means clean). Generalizes the render-time preparation refusal to
592/// binding-validation time.
593///
594/// Refuses, per D6:
595/// - a declared `sync` / `verify` operation over a `web` medium
596///   ([`CapabilityError::OperationOutOfScope`]);
597/// - a glob `deny_paths` list over a non-path-namespace medium
598///   ([`CapabilityError::GlobDenyIllegal`]);
599/// - any declared facet preparation
600///   ([`CapabilityError::PreparationUnsupported`]);
601/// - a `prune` block requesting `never-clobber` over a non-base-retrievable
602///   medium ([`CapabilityError::PruneGuaranteeUnsupported`], F1).
603///
604/// A binding whose every declared operation the matrix marks legal validates
605/// clean (`Ok(())`). This is a new, callable entry point — it is not yet wired
606/// into the live loader / resolve path.
607pub fn validate_binding(resolved: &ResolvedBinding) -> Result<(), Vec<CapabilityError>> {
608    let mut refusals = Vec::new();
609    let has_deny = !resolved.binding.deny_paths.is_empty();
610    let sync_declared = resolved.binding.operations.sync.is_some();
611    let verify_declared = resolved.binding.operations.verify.is_some();
612    // F1: a `prune` block requesting `never-clobber` needs a base-retrievable
613    // medium on every facet; refuse per-facet where it cannot be honoured.
614    let requested_prune = resolved
615        .binding
616        .prune
617        .as_ref()
618        .map(|p| p.guarantee)
619        .filter(|g| *g == PruneGuarantee::NeverClobber);
620
621    for source in &resolved.primary_sources {
622        let caps = medium_capabilities(source.medium_type);
623        let medium_type = serde_json::to_value(source.medium_type)
624            .ok()
625            .and_then(|v| v.as_str().map(str::to_string))
626            .unwrap_or_default();
627
628        // A declared preparation is always unsupported (no implementation).
629        if let Some(prep) = &source.preparation {
630            refusals.push(CapabilityError::PreparationUnsupported {
631                facet: source.facet_ref.clone(),
632                preparation: prep.clone(),
633                impl_version: PREPARATION_IMPL_VERSION,
634            });
635        }
636
637        // sync / verify over a medium with no change signal (web) is out of scope.
638        if !caps.change_signal {
639            for (declared, op) in [
640                (sync_declared, Operation::Sync),
641                (verify_declared, Operation::Verify),
642            ] {
643                if declared {
644                    refusals.push(CapabilityError::OperationOutOfScope {
645                        operation: op.name(),
646                        facet: source.facet_ref.clone(),
647                        medium_type: medium_type.clone(),
648                    });
649                }
650            }
651        }
652
653        // Glob deny_paths over a non-path-shaped namespace is illegal.
654        if has_deny && !caps.glob_deny_legal {
655            refusals.push(CapabilityError::GlobDenyIllegal {
656                facet: source.facet_ref.clone(),
657                medium_type: medium_type.clone(),
658                anchor_namespace: caps.anchor_namespace,
659            });
660        }
661
662        // F1: requested `never-clobber` prune over a non-base-retrievable medium
663        // is refused with the downgrade remedy — at validation, not run time.
664        if requested_prune.is_some() && !caps.base_version_retrievable {
665            refusals.push(CapabilityError::PruneGuaranteeUnsupported {
666                facet: source.facet_ref.clone(),
667                medium_type: medium_type.clone(),
668                requested: PruneGuarantee::NeverClobber.as_wire(),
669                supported: prune_guarantee_for_medium(source.medium_type).as_wire(),
670            });
671        }
672    }
673
674    if refusals.is_empty() {
675        Ok(())
676    } else {
677        Err(refusals)
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::pipeline::PatternMode;
685
686    // ---- builders -------------------------------------------------------
687
688    fn build_op() -> BuildOperation {
689        BuildOperation {
690            mode: BuildMode::Discovery,
691            trigger: IngestTrigger::Loop,
692            batch_size: 20,
693            post_actions: None,
694        }
695    }
696
697    fn binding() -> BindingV1 {
698        BindingV1 {
699            version: BINDING_VERSION,
700            intent: Some("prose for the agent".to_string()),
701            source_facets: vec!["source-tree".to_string()],
702            reference_mems: vec!["engine".to_string()],
703            destination_mem: "plugin".to_string(),
704            deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
705            coverage_semantics: CoverageSemantics::Exhaustive,
706            rules: Some(serde_json::json!({ "routing": "…" })),
707            prune: None,
708            operations: Operations {
709                build: Some(build_op()),
710                sync: Some(SyncOperation {
711                    trigger: IngestTrigger::Manual,
712                    batch_size: 20,
713                }),
714                verify: Some(VerifyOperation {
715                    trigger: IngestTrigger::Manual,
716                    batch_size: 20,
717                    adjudication_cap: DEFAULT_ADJUDICATION_CAP,
718                    full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
719                }),
720            },
721        }
722    }
723
724    fn allow(path: &str) -> PatternEntry {
725        PatternEntry {
726            path: path.to_string(),
727            mode: PatternMode::Allow,
728        }
729    }
730
731    fn primary(
732        facet: &str,
733        medium_type: MediumType,
734        pointer: &str,
735        scope: Vec<PatternEntry>,
736        preparation: Option<&str>,
737        change_detection: Option<&str>,
738    ) -> ResolvedPrimarySource {
739        ResolvedPrimarySource {
740            facet_ref: facet.to_string(),
741            medium: "m".to_string(),
742            medium_type,
743            medium_pointer: pointer.to_string(),
744            declared_change_detection: change_detection.map(str::to_string),
745            scope,
746            preparation: preparation.map(str::to_string),
747        }
748    }
749
750    fn resolved(binding: BindingV1, sources: Vec<ResolvedPrimarySource>) -> ResolvedBinding {
751        ResolvedBinding {
752            binding,
753            primary_sources: sources,
754        }
755    }
756
757    fn one_codebase_source() -> Vec<ResolvedPrimarySource> {
758        vec![primary(
759            "source-tree",
760            MediumType::Codebase,
761            "../public",
762            vec![allow("../public/**/*.rs")],
763            None,
764            None,
765        )]
766    }
767
768    // ---- D1: BindingV1 serde --------------------------------------------
769
770    /// A v1 binding round-trips: serialize → deserialize → equal.
771    #[test]
772    fn binding_round_trips() {
773        let b = binding();
774        let json = serde_json::to_string(&b).unwrap();
775        let back: BindingV1 = serde_json::from_str(&json).unwrap();
776        assert_eq!(back, b);
777    }
778
779    /// A real-shaped v1 binding JSON (the D1 example) deserializes, with the
780    /// operations block and coverage semantics as declared.
781    #[test]
782    fn real_shaped_v1_json_deserializes() {
783        let src = r#"{
784          "version": 1,
785          "intent": "prose for the agent",
786          "source_facets": ["source-tree"],
787          "reference_mems": ["engine"],
788          "destination_mem": "plugin",
789          "deny_paths": ["VISION.md", "dev/**"],
790          "coverage_semantics": "exhaustive",
791          "rules": { "routing": "…" },
792          "operations": {
793            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20, "post_actions": { "archive_source": true } },
794            "sync":  { "trigger": "manual", "batch_size": 20 },
795            "verify": { "trigger": "manual", "batch_size": 20 }
796          }
797        }"#;
798        let b: BindingV1 = serde_json::from_str(src).unwrap();
799        assert_eq!(b.version, 1);
800        assert_eq!(b.destination_mem, "plugin");
801        assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
802        assert_eq!(
803            b.operations.build.as_ref().unwrap().mode,
804            BuildMode::Discovery
805        );
806        assert_eq!(
807            b.operations.build.as_ref().unwrap().trigger,
808            IngestTrigger::Loop
809        );
810        assert_eq!(
811            b.operations.build.as_ref().unwrap().post_actions,
812            Some(serde_json::json!({ "archive_source": true }))
813        );
814        assert!(b.operations.sync.is_some());
815        assert!(b.operations.verify.is_some());
816    }
817
818    /// `coverage_semantics` defaults to exhaustive when absent, and `one-shot`
819    /// is the kebab wire form.
820    #[test]
821    fn coverage_defaults_and_one_shot_wire_form() {
822        let src = r#"{
823          "version": 1,
824          "destination_mem": "m",
825          "operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
826        }"#;
827        let b: BindingV1 = serde_json::from_str(src).unwrap();
828        assert_eq!(b.coverage_semantics, CoverageSemantics::Exhaustive);
829        assert_eq!(
830            b.operations.build.as_ref().unwrap().mode,
831            BuildMode::OneShot
832        );
833        assert!(b.operations.sync.is_none());
834        assert!(b.operations.verify.is_none());
835        // one-shot serializes to the kebab form.
836        assert_eq!(
837            serde_json::to_string(&BuildMode::OneShot).unwrap(),
838            r#""one-shot""#
839        );
840    }
841
842    /// D4 — the tier-3 knobs are additive: a `verify` block written before they
843    /// existed (only `trigger` + `batch_size`) deserializes to the dogfood-tuned
844    /// defaults, and a block that sets them round-trips its values.
845    #[test]
846    fn verify_tier3_knobs_default_and_round_trip() {
847        // Legacy verify block — no adjudication_cap / full_resync_every.
848        let src = r#"{
849          "version": 1,
850          "destination_mem": "m",
851          "operations": {
852            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
853            "verify": { "trigger": "manual", "batch_size": 20 }
854          }
855        }"#;
856        let b: BindingV1 = serde_json::from_str(src).unwrap();
857        let v = b.operations.verify.as_ref().unwrap();
858        assert_eq!(v.adjudication_cap, DEFAULT_ADJUDICATION_CAP);
859        assert_eq!(v.full_resync_every, DEFAULT_FULL_RESYNC_EVERY);
860
861        // Explicit values round-trip.
862        let explicit = VerifyOperation {
863            trigger: IngestTrigger::Manual,
864            batch_size: 10,
865            adjudication_cap: 7,
866            full_resync_every: 3,
867        };
868        let json = serde_json::to_string(&explicit).unwrap();
869        let back: VerifyOperation = serde_json::from_str(&json).unwrap();
870        assert_eq!(back, explicit);
871        assert!(json.contains("adjudication_cap"));
872        assert!(json.contains("full_resync_every"));
873    }
874
875    /// The tier-3 scheduling knobs never change `hash(D)` — they are excluded
876    /// with the rest of the `verify` block (scheduling never changes the claim).
877    #[test]
878    fn tier3_knobs_do_not_change_the_hash() {
879        let base = hash_binding(&resolved(binding(), one_codebase_source()));
880        let mut tuned = binding();
881        let v = tuned.operations.verify.as_mut().unwrap();
882        v.adjudication_cap = 999;
883        v.full_resync_every = 1;
884        assert_eq!(
885            base,
886            hash_binding(&resolved(tuned, one_codebase_source())),
887            "tier-3 verify knobs are excluded from hash(D)"
888        );
889    }
890
891    /// `"mode": "refinement"` is a deleted value — deserialization fails.
892    #[test]
893    fn refinement_mode_is_rejected() {
894        let src = r#"{
895          "version": 1,
896          "destination_mem": "m",
897          "operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
898        }"#;
899        let err = serde_json::from_str::<BindingV1>(src).unwrap_err();
900        assert!(
901            err.to_string().contains("refinement") || err.to_string().contains("unknown variant"),
902            "unexpected error: {err}"
903        );
904    }
905
906    /// `version` is required — a projection file without it refuses.
907    #[test]
908    fn version_is_required() {
909        let src = r#"{
910          "destination_mem": "m",
911          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
912        }"#;
913        assert!(serde_json::from_str::<BindingV1>(src).is_err());
914    }
915
916    // ---- D5: hash(D) ----------------------------------------------------
917
918    /// `hash(D)` is stable and recomputable: the same resolved binding hashes
919    /// identically, and the digest is 64 lowercase hex chars.
920    #[test]
921    fn hash_is_stable_and_recomputable() {
922        let r = resolved(binding(), one_codebase_source());
923        let h1 = hash_binding(&r);
924        let h2 = hash_binding(&r);
925        assert_eq!(h1, h2);
926        assert_eq!(h1.len(), 64);
927        assert!(
928            h1.chars()
929                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
930        );
931    }
932
933    /// Changing a facet selection pattern (an input *outside* the binding
934    /// file) changes the hash.
935    #[test]
936    fn changing_a_facet_pattern_changes_the_hash() {
937        let base = hash_binding(&resolved(binding(), one_codebase_source()));
938        let changed = hash_binding(&resolved(
939            binding(),
940            vec![primary(
941                "source-tree",
942                MediumType::Codebase,
943                "../public",
944                vec![allow("../public/**/*.md")], // different pattern
945                None,
946                None,
947            )],
948        ));
949        assert_ne!(base, changed);
950    }
951
952    /// Changing a medium pointer (an input *outside* the binding file) changes
953    /// the hash.
954    #[test]
955    fn changing_a_medium_pointer_changes_the_hash() {
956        let base = hash_binding(&resolved(binding(), one_codebase_source()));
957        let changed = hash_binding(&resolved(
958            binding(),
959            vec![primary(
960                "source-tree",
961                MediumType::Codebase,
962                "../elsewhere", // different pointer
963                vec![allow("../public/**/*.rs")],
964                None,
965                None,
966            )],
967        ));
968        assert_ne!(base, changed);
969    }
970
971    /// Changing `trigger`, `batch_size`, or `post_actions` does **not** change
972    /// the hash — scheduling never changes what the mem claims.
973    #[test]
974    fn scheduling_knobs_do_not_change_the_hash() {
975        let base = hash_binding(&resolved(binding(), one_codebase_source()));
976
977        let mut b_trigger = binding();
978        b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
979        assert_eq!(
980            base,
981            hash_binding(&resolved(b_trigger, one_codebase_source())),
982            "trigger is excluded"
983        );
984
985        let mut b_batch = binding();
986        b_batch.operations.build.as_mut().unwrap().batch_size = 999;
987        assert_eq!(
988            base,
989            hash_binding(&resolved(b_batch, one_codebase_source())),
990            "batch_size is excluded"
991        );
992
993        let mut b_post = binding();
994        b_post.operations.build.as_mut().unwrap().post_actions =
995            Some(serde_json::json!({ "archive_source": false }));
996        assert_eq!(
997            base,
998            hash_binding(&resolved(b_post, one_codebase_source())),
999            "post_actions is excluded"
1000        );
1001
1002        // The sync/verify blocks are excluded too.
1003        let mut b_sync = binding();
1004        b_sync.operations.sync = None;
1005        assert_eq!(
1006            base,
1007            hash_binding(&resolved(b_sync, one_codebase_source())),
1008            "sync block is excluded"
1009        );
1010    }
1011
1012    /// Changing `operations.build.mode` — a content-defining input — **does**
1013    /// change the hash.
1014    #[test]
1015    fn changing_build_mode_changes_the_hash() {
1016        let base = hash_binding(&resolved(binding(), one_codebase_source()));
1017        let mut b = binding();
1018        b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
1019        assert_ne!(base, hash_binding(&resolved(b, one_codebase_source())));
1020    }
1021
1022    /// An absent `build` block deserializes (serde default) and still hashes —
1023    /// the build mode simply does not participate in `hash(D)` (D1/AC4).
1024    #[test]
1025    fn absent_build_deserializes_and_hashes() {
1026        let src = r#"{
1027          "version": 1,
1028          "destination_mem": "m",
1029          "operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
1030        }"#;
1031        let b: BindingV1 = serde_json::from_str(src).unwrap();
1032        assert!(b.operations.build.is_none(), "absent build parses to None");
1033        // Hashes without panicking; build_mode is omitted from the canonical JSON.
1034        let h = hash_binding(&resolved(b, one_codebase_source()));
1035        assert_eq!(h.len(), 64);
1036    }
1037
1038    // ---- D6: capability matrix + validate -------------------------------
1039
1040    /// The matrix rows match D6's table.
1041    #[test]
1042    fn capability_matrix_matches_d6_table() {
1043        let web = medium_capabilities(MediumType::Web);
1044        assert!(!web.enumerable && !web.change_signal && !web.base_version_retrievable);
1045        assert!(!web.glob_deny_legal);
1046        assert_eq!(web.anchor_namespace, "url");
1047
1048        let graph = medium_capabilities(MediumType::Graph);
1049        assert!(graph.enumerable && graph.change_signal && graph.base_version_retrievable);
1050        assert!(!graph.glob_deny_legal, "graph namespace is not path-shaped");
1051        assert_eq!(graph.anchor_namespace, "entity");
1052
1053        for ty in [
1054            MediumType::Codebase,
1055            MediumType::Filesystem,
1056            MediumType::Git,
1057        ] {
1058            let c = medium_capabilities(ty);
1059            assert!(c.enumerable && c.change_signal && c.base_version_retrievable);
1060            assert!(c.glob_deny_legal, "{ty:?} allows glob deny_paths");
1061        }
1062        assert_eq!(
1063            medium_capabilities(MediumType::Git).anchor_namespace,
1064            "path+commit"
1065        );
1066    }
1067
1068    /// `sync` and `verify` over a `web` medium each refuse as out-of-scope.
1069    #[test]
1070    fn sync_and_verify_over_web_refuse() {
1071        // Web binding, no deny_paths (globs illegal), no prep — isolate the op refusal.
1072        let mut b = binding();
1073        b.deny_paths.clear();
1074        let sources = vec![primary(
1075            "web-facet",
1076            MediumType::Web,
1077            "https://example.com",
1078            vec![],
1079            None,
1080            None,
1081        )];
1082        let errs = validate_binding(&resolved(b, sources)).unwrap_err();
1083        let ops: Vec<&str> = errs
1084            .iter()
1085            .filter_map(|e| match e {
1086                CapabilityError::OperationOutOfScope { operation, .. } => Some(*operation),
1087                _ => None,
1088            })
1089            .collect();
1090        assert!(ops.contains(&"sync"), "sync refused: {errs:?}");
1091        assert!(ops.contains(&"verify"), "verify refused: {errs:?}");
1092    }
1093
1094    /// Glob `deny_paths` over a `graph` medium refuses.
1095    #[test]
1096    fn glob_deny_over_graph_refuses() {
1097        // Graph binding with build-only (avoid the change-signal check; graph
1098        // *does* have a change signal anyway) and a glob deny list.
1099        let mut b = binding();
1100        b.operations.sync = None;
1101        b.operations.verify = None;
1102        b.deny_paths = vec!["some/**".to_string()];
1103        let sources = vec![primary(
1104            "graph-facet",
1105            MediumType::Graph,
1106            "home",
1107            vec![],
1108            None,
1109            None,
1110        )];
1111        let errs = validate_binding(&resolved(b, sources)).unwrap_err();
1112        assert!(
1113            errs.iter()
1114                .any(|e| matches!(e, CapabilityError::GlobDenyIllegal { .. })),
1115            "expected GlobDenyIllegal, got {errs:?}"
1116        );
1117    }
1118
1119    /// A declared facet preparation refuses at validation time.
1120    #[test]
1121    fn declared_preparation_refuses() {
1122        let mut b = binding();
1123        b.operations.sync = None;
1124        b.operations.verify = None;
1125        b.deny_paths.clear();
1126        let sources = vec![primary(
1127            "manual-pages",
1128            MediumType::Filesystem,
1129            "../docs",
1130            vec![],
1131            Some("pdf-to-markdown"),
1132            None,
1133        )];
1134        let errs = validate_binding(&resolved(b, sources)).unwrap_err();
1135        assert!(
1136            errs.iter().any(|e| matches!(
1137                e,
1138                CapabilityError::PreparationUnsupported { preparation, .. } if preparation == "pdf-to-markdown"
1139            )),
1140            "expected PreparationUnsupported, got {errs:?}"
1141        );
1142    }
1143
1144    /// Every combination the matrix marks legal validates clean:
1145    /// codebase / filesystem / git / graph bindings with build+sync+verify all
1146    /// pass (graph carries no glob deny_paths, none carry preparation).
1147    #[test]
1148    fn legal_combinations_validate_clean() {
1149        // codebase / filesystem / git — path-shaped, deny_paths legal.
1150        for ty in [
1151            MediumType::Codebase,
1152            MediumType::Filesystem,
1153            MediumType::Git,
1154        ] {
1155            let sources = vec![primary(
1156                "f",
1157                ty,
1158                "../src",
1159                vec![allow("../src/**")],
1160                None,
1161                None,
1162            )];
1163            assert!(
1164                validate_binding(&resolved(binding(), sources)).is_ok(),
1165                "{ty:?} build+sync+verify should validate clean"
1166            );
1167        }
1168        // graph — build+sync+verify legal, but only without glob deny_paths.
1169        let mut graph_binding = binding();
1170        graph_binding.deny_paths.clear();
1171        let graph_sources = vec![primary("g", MediumType::Graph, "home", vec![], None, None)];
1172        assert!(
1173            validate_binding(&resolved(graph_binding, graph_sources)).is_ok(),
1174            "graph build+sync+verify with no glob deny should validate clean"
1175        );
1176    }
1177
1178    // ---- F1: prune guarantee -------------------------------------------
1179
1180    /// F1 — the `prune` block is additive: a binding written before it existed
1181    /// deserializes to `prune: None`, and a block that sets a guarantee
1182    /// round-trips (defaulting to `conflict-flag` when the guarantee is absent).
1183    #[test]
1184    fn prune_block_is_additive_and_round_trips() {
1185        // Legacy binding — no `prune`.
1186        let src = r#"{
1187          "version": 1,
1188          "destination_mem": "m",
1189          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1190        }"#;
1191        let b: BindingV1 = serde_json::from_str(src).unwrap();
1192        assert!(b.prune.is_none(), "absent prune parses to None");
1193
1194        // A prune block with no guarantee defaults to conflict-flag.
1195        let with_default = r#"{
1196          "version": 1,
1197          "destination_mem": "m",
1198          "prune": {},
1199          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1200        }"#;
1201        let b: BindingV1 = serde_json::from_str(with_default).unwrap();
1202        assert_eq!(
1203            b.prune.as_ref().unwrap().guarantee,
1204            PruneGuarantee::ConflictFlag
1205        );
1206
1207        // Explicit never-clobber round-trips.
1208        let explicit = PruneConfig {
1209            guarantee: PruneGuarantee::NeverClobber,
1210        };
1211        let json = serde_json::to_string(&explicit).unwrap();
1212        assert!(json.contains("never-clobber"));
1213        assert_eq!(
1214            serde_json::from_str::<PruneConfig>(&json).unwrap(),
1215            explicit
1216        );
1217    }
1218
1219    /// F1 — the `prune` policy never changes `hash(D)` (it is a maintenance
1220    /// policy, excluded like the sync/verify blocks).
1221    #[test]
1222    fn prune_does_not_change_the_hash() {
1223        let base = hash_binding(&resolved(binding(), one_codebase_source()));
1224        let mut pruned = binding();
1225        pruned.prune = Some(PruneConfig {
1226            guarantee: PruneGuarantee::NeverClobber,
1227        });
1228        assert_eq!(
1229            base,
1230            hash_binding(&resolved(pruned, one_codebase_source())),
1231            "prune policy is excluded from hash(D)"
1232        );
1233    }
1234
1235    /// F1 — the strongest guarantee a medium supports is base-leg-retrievability:
1236    /// git-backed mediums support never-clobber; `web` supports only conflict-flag.
1237    #[test]
1238    fn prune_guarantee_per_medium_matches_capability_matrix() {
1239        for ty in [
1240            MediumType::Codebase,
1241            MediumType::Filesystem,
1242            MediumType::Git,
1243            MediumType::Graph,
1244        ] {
1245            assert_eq!(
1246                prune_guarantee_for_medium(ty),
1247                PruneGuarantee::NeverClobber,
1248                "{ty:?} can retrieve a base leg → never-clobber"
1249            );
1250        }
1251        assert_eq!(
1252            prune_guarantee_for_medium(MediumType::Web),
1253            PruneGuarantee::ConflictFlag,
1254            "web has no retrievable base leg → conflict-flag only"
1255        );
1256    }
1257
1258    /// F1 REFUSAL — requesting `never-clobber` prune over a `web` medium (no
1259    /// retrievable base leg) fails at binding validation with a remedy-bearing
1260    /// error naming the downgrade, never a runtime surprise.
1261    #[test]
1262    fn never_clobber_prune_over_web_refuses_with_remedy() {
1263        let mut b = binding();
1264        b.operations.sync = None; // isolate the prune refusal from op-out-of-scope
1265        b.operations.verify = None;
1266        b.deny_paths.clear();
1267        b.prune = Some(PruneConfig {
1268            guarantee: PruneGuarantee::NeverClobber,
1269        });
1270        let sources = vec![primary(
1271            "web-facet",
1272            MediumType::Web,
1273            "https://example.com",
1274            vec![],
1275            None,
1276            None,
1277        )];
1278        let errs = validate_binding(&resolved(b, sources)).unwrap_err();
1279        let refusal = errs
1280            .iter()
1281            .find_map(|e| match e {
1282                CapabilityError::PruneGuaranteeUnsupported {
1283                    requested,
1284                    supported,
1285                    ..
1286                } => Some((*requested, *supported)),
1287                _ => None,
1288            })
1289            .expect("expected a PruneGuaranteeUnsupported refusal");
1290        assert_eq!(refusal, ("never-clobber", "conflict-flag"));
1291        // The message carries the concrete downgrade remedy.
1292        let msg = errs
1293            .iter()
1294            .find(|e| matches!(e, CapabilityError::PruneGuaranteeUnsupported { .. }))
1295            .unwrap()
1296            .to_string();
1297        assert!(
1298            msg.contains("conflict-flag"),
1299            "remedy names the downgrade: {msg}"
1300        );
1301    }
1302
1303    /// F1 — `never-clobber` over a git-backed medium validates clean, and
1304    /// `conflict-flag` (the always-supportable degradation) validates clean over
1305    /// `web` — the guarantee the matrix marks legal is accepted.
1306    #[test]
1307    fn prune_guarantee_supported_validates_clean() {
1308        // never-clobber over codebase — base retrievable, clean.
1309        let mut nc = binding();
1310        nc.prune = Some(PruneConfig {
1311            guarantee: PruneGuarantee::NeverClobber,
1312        });
1313        assert!(validate_binding(&resolved(nc, one_codebase_source())).is_ok());
1314
1315        // conflict-flag over web — always supportable (build-only to isolate).
1316        let mut cf = binding();
1317        cf.operations.sync = None;
1318        cf.operations.verify = None;
1319        cf.deny_paths.clear();
1320        cf.prune = Some(PruneConfig {
1321            guarantee: PruneGuarantee::ConflictFlag,
1322        });
1323        let web = vec![primary(
1324            "web-facet",
1325            MediumType::Web,
1326            "https://example.com",
1327            vec![],
1328            None,
1329            None,
1330        )];
1331        assert!(validate_binding(&resolved(cf, web)).is_ok());
1332    }
1333
1334    /// A `web` binding scaffolded build-only (no sync/verify, no deny, no prep)
1335    /// validates clean — the matrix-filtered default.
1336    #[test]
1337    fn web_build_only_validates_clean() {
1338        let mut b = binding();
1339        b.operations.sync = None;
1340        b.operations.verify = None;
1341        b.deny_paths.clear();
1342        let sources = vec![primary(
1343            "web-facet",
1344            MediumType::Web,
1345            "https://example.com",
1346            vec![],
1347            None,
1348            None,
1349        )];
1350        assert!(validate_binding(&resolved(b, sources)).is_ok());
1351    }
1352}