Skip to main content

memstead_base/
binding.rs

1//! Binding format **v2** — one record per pipeline.
2//!
3//! This is the **live** binding shape: [`crate::pipeline_store::load_pipeline_configs`]
4//! reads it (version-gated), the `projection` CLI tree writes it, and the
5//! resolve / brief / status / advance paths consume it. A v2 [`Binding`]
6//! alone fully defines a pipeline: intent, **inline sources** (each carrying
7//! what the retired standalone medium + facet records carried), reference
8//! mems, destination, deny paths, coverage semantics, and operations. The
9//! 2026-07 consolidation (operator directive, 2026-07-18) removed the
10//! three-file store: the engine reads only this format; `memstead projection
11//! migrate` converts prior generations, and there is no compatibility layer.
12//!
13//! Three things live here:
14//!
15//! 1. [`Binding`] — the versioned record: one file per pipeline, collapsing
16//!    the medium / facet / binding split into a single record with inline
17//!    [`Source`] entries and an `operations { build, sync, verify }` block.
18//! 2. [`hash_binding`] — `hash(D)`: the lowercase-hex SHA-256 of the
19//!    canonical JSON of the binding's *content-defining* projection.
20//!    Scheduling knobs (`trigger` / `batch_size` / `post_actions`, the
21//!    sync/verify blocks, prune) are excluded by construction; a source's
22//!    selection pattern or pointer changing — now inputs *inside* the one
23//!    record — changes the hash.
24//! 3. [`medium_capabilities`] + [`validate_binding`] — the medium-capability
25//!    matrix (the medium *half* of a source description keeps the medium
26//!    vocabulary) and the validation entry point: capability refusals plus
27//!    in-record source validation (empty / duplicate source names).
28//!
29//! The findings store ([`crate::ingest::findings`]) keys on `hash(D)`, so the
30//! consolidation's shape change invalidates prior findings by construction —
31//! accepted and disclosed (findings are re-derivable measurements).
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest as _, Sha256};
35
36use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, Source};
37
38/// The current binding format version. A v2 binding carries `version: 2`.
39pub const BINDING_VERSION: u32 = 2;
40
41/// The engine's current preparation-implementation version — the single
42/// source of truth for "which preparation implementation is live".
43///
44/// No preparation implementation exists yet, so this is `0` ("none"). It
45/// nonetheless participates in [`hash_binding`]: a future preparation
46/// implementation bumps this constant, which — because the preparation
47/// identifier + this version are both hashed — invalidates every prior
48/// finding keyed on the old `hash(D)` by construction.
49pub const PREPARATION_IMPL_VERSION: u32 = 0;
50
51// ---------------------------------------------------------------------------
52// The v2 record
53// ---------------------------------------------------------------------------
54
55/// Coverage semantics — whether the binding claims to cover *everything* in
56/// its declared scope (`exhaustive`) or a deliberately partial slice
57/// (`curated`).
58///
59/// On the [`Binding`] record the field is **optional**: absent means "not
60/// stated", which is a different fact from "stated as exhaustive". The
61/// effective value is resolved per medium by
62/// [`effective_coverage_semantics`] — there is deliberately no `Default`
63/// impl, because a default is exactly the silence-as-assertion this
64/// design retired.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum CoverageSemantics {
68    /// Every artifact in scope is expected to be accounted for.
69    Exhaustive,
70    /// A deliberately partial selection — an unaccounted artifact is
71    /// information, not a defect.
72    Curated,
73}
74
75/// How a [`BuildOperation`] engages its binding. **`refinement` is deleted
76/// from the vocabulary** — it is neither a variant here nor migrated, so
77/// deserializing `"mode": "refinement"` fails as an unknown value.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "kebab-case")]
80pub enum BuildMode {
81    /// Build out new coverage.
82    Discovery,
83    /// A single bounded pass.
84    OneShot,
85}
86
87/// The **build** operation — the only operation carrying a mode. Grows new
88/// coverage (or runs a one-shot lens). `trigger` / `batch_size` /
89/// `post_actions` are scheduling attributes, excluded from [`hash_binding`].
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BuildOperation {
92    /// Discovery / one-shot. The one operation with a mode.
93    pub mode: BuildMode,
94    /// What sets this operation running (loop / manual / on-event).
95    pub trigger: IngestTrigger,
96    /// How many artifacts a single run processes.
97    pub batch_size: u32,
98    /// Free-form post-run actions (e.g. a one-shot `archive_source` flag).
99    /// Opaque to the engine — consumed only by the one-shot brief renderer.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub post_actions: Option<serde_json::Value>,
102}
103
104/// The **sync** operation — the (future) sole maintenance writer. Optional: an
105/// absent `sync` block makes that *mutating* operation refuse at run time.
106/// Carries no mode.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SyncOperation {
109    /// What sets a sync running.
110    pub trigger: IngestTrigger,
111    /// How many artifacts a single run processes.
112    pub batch_size: u32,
113}
114
115/// Default per-run tier-3 adjudication cap (bundle plan `05-verify-sync-engine`,
116/// D1/D4). Dogfood-tuned against the live `engine/graph` binding (524 source
117/// artifacts): a fully-drifted mem of that scale clears its adjudication backlog
118/// in ~11 verify runs while each run's asserted-drift work stays bounded and its
119/// token cost predictable. `0` disables the cap (adjudicate every candidate).
120pub const DEFAULT_ADJUDICATION_CAP: u32 = 50;
121
122/// Default `full_resync_every` (bundle plan `05-verify-sync-engine`, D3/D4):
123/// fire a guaranteed full-enumeration coverage sweep every N verify runs.
124/// Dogfood-tuned against `engine/graph` (524 artifacts, sample batch 20 → a
125/// rotation completes in ~27 runs): a sweep every 20 runs guarantees a complete
126/// coverage picture without waiting on the rotation to happen to finish. `0`
127/// disables scheduled full walks (rotating sample only).
128pub const DEFAULT_FULL_RESYNC_EVERY: u32 = 20;
129
130fn default_adjudication_cap() -> u32 {
131    DEFAULT_ADJUDICATION_CAP
132}
133
134fn default_full_resync_every() -> u32 {
135    DEFAULT_FULL_RESYNC_EVERY
136}
137
138/// The **verify** operation — measurement. Optional: an absent `verify`
139/// block means engine defaults, never a refusal (verify has no mutating
140/// operation to gate). Mutates no entity, but records findings, backfills
141/// observed anchor hashes and writes a `#verified` baseline. Carries no mode.
142///
143/// `adjudication_cap` and `full_resync_every` are the tier-3 operations knobs
144/// (bundle plan `05-verify-sync-engine`, group D): scheduling attributes on the
145/// measurement side only — like `trigger` / `batch_size`, they never change what
146/// the mem claims, so they are excluded from [`hash_binding`] (the whole
147/// `verify` block is). Both are additive: an older `verify` block without them
148/// deserializes to the dogfood-tuned defaults.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct VerifyOperation {
151    /// What sets a verify running.
152    pub trigger: IngestTrigger,
153    /// How many artifacts a single run processes.
154    pub batch_size: u32,
155    /// Per-run tier-3 adjudication cap: the maximum number of hash-drift
156    /// adjudications a single verify run asserts. Once the cap is reached the
157    /// run **stops adjudicating** and queues the remaining drift candidates as
158    /// `queued-for-adjudication` findings (the tier-3 backlog the fidelity
159    /// report renders). Combined with the rotating sample, successive runs
160    /// adjudicate different windows, so the whole anchor set is covered over a
161    /// full rotation. `0` disables the cap. Defaults to
162    /// [`DEFAULT_ADJUDICATION_CAP`].
163    #[serde(default = "default_adjudication_cap")]
164    pub adjudication_cap: u32,
165    /// Scheduled full-enumeration walk cadence: every N verify runs, a full
166    /// coverage sweep enumerates the whole source set (`S(D)`) for **enumerable**
167    /// mediums, guaranteeing eventual complete coverage rather than relying on
168    /// the rotating sample to finish. For a medium the capability matrix marks
169    /// **non-enumerable**, the scheduled walk refuses with a typed signal — never
170    /// a silent skip, never a fabricated full-coverage claim. `0` disables
171    /// scheduled full walks. Defaults to [`DEFAULT_FULL_RESYNC_EVERY`].
172    #[serde(default = "default_full_resync_every")]
173    pub full_resync_every: u32,
174}
175
176/// The prune guarantee a binding **requests** (bundle plan
177/// `05-verify-sync-engine`, F1). Prune produces deletion **proposals** surfaced
178/// in the sync brief (it never mutates the mem); the guarantee governs how a
179/// prune proposal treats a model-side edit that races a source removal.
180///
181/// The guarantee a medium can *support* is derived from its base-leg
182/// retrievability ([`prune_guarantee_for_medium`]): a git-backed source can
183/// retrieve the base leg for a real three-way merge ([`Self::NeverClobber`]);
184/// everything else degrades to conflict-flagging ([`Self::ConflictFlag`]).
185/// Requesting a guarantee the medium cannot support is refused at
186/// **binding-validation** time (never at run time) via
187/// [`CapabilityError::PruneGuaranteeUnsupported`].
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
189#[serde(rename_all = "kebab-case")]
190pub enum PruneGuarantee {
191    /// Full never-clobber three-way merge — only where the source **base leg is
192    /// retrievable** (git-backed sources). The retrieved base lets the merge
193    /// tell a model-side edit apart from a clean removal, so a divergence is
194    /// never silently proposed as a clean delete.
195    NeverClobber,
196    /// Conflict-flag degradation (the default — always supportable): where the
197    /// base leg is **not** retrievable, prune presents **both** sides and never
198    /// auto-writes over a model-side edit. The decided posture for non-git
199    /// sources (span-snapshot base legs are out of scope — no current payer).
200    #[default]
201    ConflictFlag,
202}
203
204impl PruneGuarantee {
205    /// Stable wire form.
206    pub fn as_wire(&self) -> &'static str {
207        match self {
208            PruneGuarantee::NeverClobber => "never-clobber",
209            PruneGuarantee::ConflictFlag => "conflict-flag",
210        }
211    }
212}
213
214/// The **prune** configuration of a [`Binding`] (F1) — additive, optional. An
215/// absent `prune` block means prune is not enabled for the binding (no deletion
216/// proposals are produced). Prune has no independent schedule: it rides the sync
217/// brief (the sole maintenance-writer channel), so it carries no `trigger` /
218/// `batch_size` — only the requested [`PruneGuarantee`]. Like the `sync` /
219/// `verify` blocks it is **excluded from [`hash_binding`]**: a maintenance
220/// policy never changes what the mem claims.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct PruneConfig {
223    /// The guarantee level the binding requests. Validated against the medium's
224    /// base-leg retrievability at binding-validation time (F1 refusal).
225    /// Defaults to [`PruneGuarantee::ConflictFlag`] when absent.
226    #[serde(default)]
227    pub guarantee: PruneGuarantee,
228}
229
230/// The operations block of a [`Binding`]: every operation is **optional**.
231/// An absent `build` / `sync` block makes that *mutating* operation
232/// refuse at run time with a `projection enable <op>` remedy; an absent
233/// `verify` block means engine defaults (verify has no mutating operation to
234/// gate — never a refusal). `build` is optional in serde so an absent block yields the
235/// remedy-bearing refusal rather than a generic "missing field" parse error.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct Operations {
238    /// The build operation (optional — absent = mutating op refuses with the
239    /// `projection enable build` remedy at run time).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub build: Option<BuildOperation>,
242    /// The sync operation (optional — absent = mutating op refuses with the
243    /// `projection enable sync` remedy at run time).
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub sync: Option<SyncOperation>,
246    /// The verify operation (optional — absent = engine defaults, never a refusal).
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub verify: Option<VerifyOperation>,
249}
250
251/// Default `deny_paths` scaffolded onto a fresh enumerable
252/// (`codebase` / `filesystem`) binding: ordinary platform/tooling
253/// debris that would otherwise flood a first denominator. A default,
254/// not an invariant — the scaffold materialises the list into the
255/// binding record, so an author who wants one of these in scope
256/// deletes the entry and gets the files back; bindings created before
257/// the default existed keep their recorded (empty) list. Engine state
258/// (`.memstead/`, `.memstead.cache/`, mount storage) is NOT on this
259/// list — its exclusion is unconditional in the strategy layer, never
260/// a deletable record entry.
261pub const DEFAULT_SCAFFOLD_DENY_PATHS: &[&str] = &[
262    "**/.DS_Store",
263    "**/.git/**",
264    "**/node_modules/**",
265    "**/Thumbs.db",
266];
267
268/// A **binding**, format version 2 — one record per pipeline. The single
269/// versioned file at `projections/<mem>/<name>.json` that alone fully defines
270/// the obligation: `intent`, inline [`Source`] entries (each carrying the
271/// medium and facet halves the retired standalone records held),
272/// `reference_mems`, `destination_mem`, `deny_paths`, `coverage_semantics`,
273/// `rules`, `prune`, and the `operations { build, sync, verify }` block.
274///
275/// This is the live store record — [`crate::pipeline_store::load_pipeline_configs`]
276/// reads it version-gated and the `projection` CLI tree writes it.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct Binding {
279    /// Format version — required. v2 is [`BINDING_VERSION`]. A projection file
280    /// without it (or with a prior version) is refused by the loader with a
281    /// typed error naming `memstead projection migrate`.
282    pub version: u32,
283    /// What the binding is trying to accomplish — prose for the agent.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub intent: Option<String>,
286    /// The inline sources the binding consumes, in declaration order.
287    /// Each `name` is unique within the record and keys per-source state.
288    #[serde(default)]
289    pub sources: Vec<Source>,
290    /// Read-only reference mems that supply cross-mem context.
291    #[serde(default)]
292    pub reference_mems: Vec<String>,
293    /// The mem this binding writes into.
294    pub destination_mem: String,
295    /// Paths excluded from the binding's scope (workspace-relative globs).
296    #[serde(default)]
297    pub deny_paths: Vec<String>,
298    /// Whether the binding claims exhaustive or curated coverage.
299    /// Optional: `None` means **not stated** — a different fact from
300    /// "stated as exhaustive". Consumers never read this raw; they read
301    /// [`effective_coverage_semantics`], which resolves `None` per
302    /// medium (all sources enumerable → exhaustive; any non-enumerable
303    /// source → curated). An explicit `exhaustive` over a
304    /// non-enumerable source is refused by [`validate_binding`].
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub coverage_semantics: Option<CoverageSemantics>,
307    /// Free-form binding rules (e.g. a one-shot lens `routing` string).
308    /// Opaque to the engine — consumed only by the one-shot brief renderer.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub rules: Option<serde_json::Value>,
311    /// The **prune** policy (bundle plan `05-verify-sync-engine`, F1) — additive,
312    /// optional. Absent = prune disabled (no deletion proposals). Present = prune
313    /// produces deletion proposals in the sync brief under the requested
314    /// [`PruneGuarantee`], validated against the medium's base-leg
315    /// retrievability at binding-validation time. Excluded from [`hash_binding`]
316    /// (a maintenance policy, not content-defining).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub prune: Option<PruneConfig>,
319    /// The operations this binding declares (build required; sync/verify optional).
320    pub operations: Operations,
321}
322
323// ---------------------------------------------------------------------------
324// hash(D)
325// ---------------------------------------------------------------------------
326
327/// One source's content-defining projection, in a fixed serde shape so
328/// [`hash_binding`] hashes every content input. Private — the hash is the
329/// only consumer.
330#[derive(Serialize)]
331struct HashSource<'a> {
332    source: &'a str,
333    patterns: &'a [PatternEntry],
334    preparation: &'a Option<String>,
335    preparation_impl_version: u32,
336    medium_type: MediumType,
337    pointer: &'a str,
338    change_detection: &'a Option<String>,
339}
340
341/// The content-defining projection of a binding, in a fixed serde shape.
342/// Private — serialized to canonical JSON for hashing. Excludes `trigger`,
343/// `batch_size`, `post_actions`, and the `sync` / `verify` / `prune` blocks:
344/// scheduling and maintenance policy never change what the mem claims. The
345/// `engagement` slot is likewise excluded (an engagement contract shapes how
346/// an agent works, not what the mem claims — the pre-consolidation exclusion
347/// carried forward).
348#[derive(Serialize)]
349struct HashInput<'a> {
350    version: u32,
351    intent: &'a Option<String>,
352    sources: Vec<HashSource<'a>>,
353    reference_mems: &'a [String],
354    destination_mem: &'a str,
355    deny_paths: &'a [String],
356    coverage_semantics: CoverageSemantics,
357    rules: &'a Option<serde_json::Value>,
358    /// The build mode participates in `hash(D)`; an absent build block simply
359    /// does not contribute it (skipped from the canonical JSON).
360    #[serde(skip_serializing_if = "Option::is_none")]
361    build_mode: Option<BuildMode>,
362}
363
364/// Serialize a JSON value with **recursively sorted object keys** and no
365/// insignificant whitespace — the canonical form. serde_json's map is a
366/// sorted `BTreeMap` today; this rebuild makes the canonicalization explicit
367/// and robust even if the `preserve_order` feature is ever enabled build-wide.
368fn canonical_json(value: &serde_json::Value) -> String {
369    fn sorted(v: &serde_json::Value) -> serde_json::Value {
370        match v {
371            serde_json::Value::Object(map) => {
372                let mut keys: Vec<&String> = map.keys().collect();
373                keys.sort();
374                let mut out = serde_json::Map::new();
375                for k in keys {
376                    out.insert(k.clone(), sorted(&map[k]));
377                }
378                serde_json::Value::Object(out)
379            }
380            serde_json::Value::Array(items) => {
381                serde_json::Value::Array(items.iter().map(sorted).collect())
382            }
383            other => other.clone(),
384        }
385    }
386    serde_json::to_string(&sorted(value)).expect("canonical JSON serializes")
387}
388
389/// Compute `hash(D)` — the lowercase-hex SHA-256 of the canonical JSON of a
390/// binding's content-defining projection.
391///
392/// Hashed: `version`, `intent`, `sources` (per source: its name, selection
393/// patterns, preparation identifier + [`PREPARATION_IMPL_VERSION`], and its
394/// medium half's `type` / `pointer` / `change_detection`), `reference_mems`,
395/// `destination_mem`, `deny_paths`, `coverage_semantics`, `rules`, and
396/// `operations.build.mode`.
397///
398/// **Excluded:** `trigger`, `batch_size`, `post_actions`, the `sync` /
399/// `verify` / `prune` blocks, and each source's `engagement` contract —
400/// scheduling, maintenance policy, and engagement style never change what
401/// the mem claims. The v2 record needs no external resolution: every content
402/// input lives inside the one record, so a selection or pointer edit
403/// invalidates the hash — and thus any findings keyed on it — directly.
404pub fn hash_binding(binding: &Binding) -> String {
405    let sources: Vec<HashSource<'_>> = binding
406        .sources
407        .iter()
408        .map(|s| HashSource {
409            source: &s.name,
410            patterns: &s.scope,
411            preparation: &s.preparation,
412            preparation_impl_version: PREPARATION_IMPL_VERSION,
413            medium_type: s.medium_type,
414            pointer: &s.pointer,
415            change_detection: &s.change_detection,
416        })
417        .collect();
418
419    let input = HashInput {
420        version: binding.version,
421        intent: &binding.intent,
422        sources,
423        reference_mems: &binding.reference_mems,
424        destination_mem: &binding.destination_mem,
425        deny_paths: &binding.deny_paths,
426        // The RESOLVED effective value, never the `Option`: a binding
427        // over enumerable sources that never declared the field keeps
428        // its pre-optionality hash byte-for-byte (resolved
429        // `exhaustive` == the old default), so its findings survive. A
430        // non-enumerable-source binding that declared nothing rehashes
431        // exactly once — correct, its asserted coverage genuinely
432        // changed.
433        coverage_semantics: effective_coverage_semantics(binding).value,
434        rules: &binding.rules,
435        build_mode: binding.operations.build.as_ref().map(|b| b.mode),
436    };
437
438    let value = serde_json::to_value(&input).expect("hash input serializes to a JSON value");
439    let canonical = canonical_json(&value);
440    let digest = Sha256::digest(canonical.as_bytes());
441    crate::hex_lower(&digest)
442}
443
444// ---------------------------------------------------------------------------
445// Medium-capability matrix + validation
446// ---------------------------------------------------------------------------
447
448/// What a medium can support — the row of the capability matrix for a
449/// [`MediumType`] (the medium *half* of a source description). Pure data;
450/// [`validate_binding`] reads it to refuse operations a medium cannot support.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub struct MediumCapabilities {
453    /// Can the medium's scope be enumerated (`S(D)` computable)?
454    pub enumerable: bool,
455    /// Does the medium provide a change signal?
456    pub change_signal: bool,
457    /// Can a base version be retrieved (for three-way merge)?
458    pub base_version_retrievable: bool,
459    /// The medium's anchor namespace (`path`, `path+commit`, `entity`, `url`).
460    pub anchor_namespace: &'static str,
461    /// Is a glob `deny_paths` list legal (i.e. is the namespace path-shaped)?
462    pub glob_deny_legal: bool,
463}
464
465/// The capability-matrix row for a medium type. The single source of
466/// truth the fidelity report also renders.
467pub fn medium_capabilities(medium_type: MediumType) -> MediumCapabilities {
468    match medium_type {
469        MediumType::Codebase => MediumCapabilities {
470            enumerable: true,
471            change_signal: true,
472            base_version_retrievable: true,
473            anchor_namespace: "path",
474            glob_deny_legal: true,
475        },
476        MediumType::Filesystem => MediumCapabilities {
477            enumerable: true,
478            change_signal: true,
479            base_version_retrievable: true,
480            anchor_namespace: "path",
481            glob_deny_legal: true,
482        },
483        MediumType::Git => MediumCapabilities {
484            enumerable: true,
485            change_signal: true,
486            base_version_retrievable: true,
487            anchor_namespace: "path+commit",
488            glob_deny_legal: true,
489        },
490        MediumType::Graph => MediumCapabilities {
491            enumerable: true,
492            change_signal: true,
493            base_version_retrievable: true,
494            anchor_namespace: "entity",
495            glob_deny_legal: false,
496        },
497        MediumType::Web => MediumCapabilities {
498            // Web enumeration / change detection / base retrieval are all
499            // deferred this cycle (operator decision 7).
500            enumerable: false,
501            change_signal: false,
502            base_version_retrievable: false,
503            anchor_namespace: "url",
504            glob_deny_legal: false,
505        },
506    }
507}
508
509/// The effective coverage of a binding plus its provenance — whether the
510/// value was declared by the author or resolved from the sources' media.
511/// The fidelity report renders the distinction; every other consumer
512/// reads only [`Self::value`].
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub struct EffectiveCoverage {
515    /// The coverage every consumer acts on.
516    pub value: CoverageSemantics,
517    /// `true` when the binding declared the field; `false` when the
518    /// value was resolved from the medium capabilities.
519    pub declared: bool,
520}
521
522/// Resolve a binding's **effective** coverage semantics. A declared value
523/// wins (validation has already refused an illegal `exhaustive`). An
524/// undeclared value resolves per binding, not per source: all sources on
525/// enumerable media → `exhaustive`; at least one non-enumerable source →
526/// `curated` — a mixed binding can only honestly claim the weaker of its
527/// parts, because coverage is an obligation of the binding as a whole
528/// (the artifact that is measured, reported, and keyed).
529pub fn effective_coverage_semantics(binding: &Binding) -> EffectiveCoverage {
530    if let Some(declared) = binding.coverage_semantics {
531        return EffectiveCoverage {
532            value: declared,
533            declared: true,
534        };
535    }
536    let all_enumerable = binding
537        .sources
538        .iter()
539        .all(|s| medium_capabilities(s.medium_type).enumerable);
540    EffectiveCoverage {
541        value: if all_enumerable {
542            CoverageSemantics::Exhaustive
543        } else {
544            CoverageSemantics::Curated
545        },
546        declared: false,
547    }
548}
549
550/// The strongest prune guarantee a medium can **support** (F1), derived from
551/// the capability matrix: a base-leg-retrievable medium (git-backed —
552/// codebase / filesystem / git / graph) supports the full never-clobber
553/// three-way merge; a non-retrievable medium (`web`) supports only conflict-flag
554/// degradation. Validation refuses a request that exceeds this.
555pub fn prune_guarantee_for_medium(medium_type: MediumType) -> PruneGuarantee {
556    if medium_capabilities(medium_type).base_version_retrievable {
557        PruneGuarantee::NeverClobber
558    } else {
559        PruneGuarantee::ConflictFlag
560    }
561}
562
563/// A binding operation subject to capability validation.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub enum Operation {
566    /// The sync (maintenance-write) operation.
567    Sync,
568    /// The verify (measurement) operation.
569    Verify,
570}
571
572impl Operation {
573    /// The lowercase name used in refusal messages.
574    fn name(self) -> &'static str {
575        match self {
576            Operation::Sync => "sync",
577            Operation::Verify => "verify",
578        }
579    }
580}
581
582/// A validation-time refusal: a capability the source's medium half cannot
583/// support, or a malformed in-record source declaration. Every refusal names
584/// the offending source so it is diagnosable without re-reading the store.
585#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
586pub enum CapabilityError {
587    /// A source has an empty `name` — the name keys per-source sync/verify
588    /// state, so it must be present.
589    #[error("a source has an empty name: every source names itself (the name keys its state)")]
590    EmptySourceName,
591    /// Two sources in the record share a name — per-source state keys would
592    /// collide.
593    #[error(
594        "duplicate source name '{name}': source names are unique within a binding \
595         (they key per-source sync/verify state)"
596    )]
597    DuplicateSourceName {
598        /// The colliding name.
599        name: String,
600    },
601    /// A `sync` / `verify` operation is declared over a medium that cannot
602    /// support it this cycle (a `web` source — operator decision 7). The
603    /// out-of-scope statement is said out loud, never a silent mtime-over-URL.
604    #[error(
605        "operation '{operation}' is out of scope for source '{source_name}' over a '{medium_type}' \
606         medium: this medium has no change signal this cycle (deferred — operator decision 7)"
607    )]
608    OperationOutOfScope {
609        /// The offending operation.
610        operation: &'static str,
611        /// The source declaring it.
612        source_name: String,
613        /// The medium type that cannot support the operation.
614        medium_type: String,
615    },
616    /// A `graph` source's scope carries a pattern the entity-namespace
617    /// vocabulary does not define. Refused at declaration rather than
618    /// silently selecting nothing: a scope that looks like selection but
619    /// reaches nothing is the defect this rule exists to prevent.
620    #[error(
621        "scope pattern '{pattern}' on source '{source_name}' is not a legal entity selector: a \
622         graph medium selects entities, not paths — write '*' for the whole mem, \
623         'type:<entity_type>', or 'id:<glob>'"
624    )]
625    GraphScopeNotEntitySelector {
626        /// The source declaring it.
627        source_name: String,
628        /// The offending pattern, verbatim.
629        pattern: String,
630    },
631    /// A source's scope carries a pattern its medium has no vocabulary to
632    /// express at all, so nothing anywhere can interpret it. Distinct from
633    /// [`Self::GraphScopeNotEntitySelector`], which names the legal forms
634    /// because a legal form exists; here there is none, so the only honest
635    /// scope is no scope.
636    #[error(
637        "scope pattern '{pattern}' on source '{source_name}' cannot be interpreted: a \
638         '{medium_type}' medium has no scope vocabulary, so the pattern would select \
639         nothing while looking like selection — remove the scope rule"
640    )]
641    ScopeNotInterpretable {
642        /// The source declaring it.
643        source_name: String,
644        /// The offending pattern, verbatim.
645        pattern: String,
646        /// The medium with no scope vocabulary.
647        medium_type: String,
648    },
649    /// Glob `deny_paths` are declared over a medium whose namespace is not
650    /// path-shaped (`graph`, `web`) — a glob cannot select in that namespace.
651    #[error(
652        "glob deny_paths are illegal for source '{source_name}' over a '{medium_type}' medium: its \
653         '{anchor_namespace}' namespace is not path-shaped"
654    )]
655    GlobDenyIllegal {
656        /// The offending source.
657        source_name: String,
658        /// The medium type whose namespace is not path-shaped.
659        medium_type: String,
660        /// That medium's anchor namespace.
661        anchor_namespace: &'static str,
662    },
663    /// A source declares a deterministic preparation step. No preparation
664    /// implementation exists ([`PREPARATION_IMPL_VERSION`] is `0`), so any
665    /// declared preparation is unsupported.
666    ///
667    /// Raised by [`validate_binding`], which the edit/validate paths call —
668    /// NOT `projection init` (which has no `--preparation` flag) and not the
669    /// brief renderer. A record that acquires a preparation some other way is
670    /// therefore accepted at rest and skipped at run time with exit 0; see
671    /// `GLOSSARY.md` and `crate::pipeline::Source::preparation`. "Refused at
672    /// validation" is true of this error's own call sites, not of every path
673    /// by which a preparation can reach a record.
674    #[error(
675        "source '{source_name}' declares preparation '{preparation}', which has no implementation \
676         (preparation impl version {impl_version})"
677    )]
678    PreparationUnsupported {
679        /// The offending source.
680        source_name: String,
681        /// The declared preparation identifier.
682        preparation: String,
683        /// The current preparation-implementation version (`0` = none).
684        impl_version: u32,
685    },
686    /// The binding declares `coverage_semantics: exhaustive` while at least
687    /// one source sits on a medium whose scope the engine cannot enumerate
688    /// (`web`) — `S(D)` is not computable, so exhaustive coverage cannot be
689    /// asserted over it. Refused at binding-validation time with `curated`
690    /// as the remedy. An *undeclared* field never trips this: it resolves
691    /// per medium via [`effective_coverage_semantics`].
692    #[error(
693        "coverage_semantics 'exhaustive' is unsupported for source '{source_name}' over a \
694         '{medium_type}' medium: its scope is not enumerable (S(D) is not computable), so \
695         exhaustive coverage cannot be asserted — declare 'curated', or omit the field to \
696         resolve per medium"
697    )]
698    CoverageExhaustiveUnsupported {
699        /// The offending source.
700        source_name: String,
701        /// The medium type whose scope is not enumerable.
702        medium_type: String,
703    },
704    /// The binding requests a `prune` guarantee the source's medium cannot
705    /// support (F1) — `never-clobber` over a medium whose base leg is not
706    /// retrievable (`web`). Refused at binding-validation time with the
707    /// downgrade remedy, never discovered at run time.
708    #[error(
709        "prune guarantee '{requested}' is unsupported for source '{source_name}' over a \
710         '{medium_type}' medium: its base leg is not retrievable, so only '{supported}' \
711         degradation is possible — set the binding's prune guarantee to '{supported}', or \
712         point the source at a git-backed medium"
713    )]
714    PruneGuaranteeUnsupported {
715        /// The offending source.
716        source_name: String,
717        /// The medium type that cannot support the requested guarantee.
718        medium_type: String,
719        /// The requested guarantee wire string.
720        requested: &'static str,
721        /// The strongest guarantee this medium supports (the downgrade remedy).
722        supported: &'static str,
723    },
724}
725
726/// Validate a binding against the medium-capability matrix and the in-record
727/// source rules, returning **every** refusal (empty `Err` never returned —
728/// `Ok` means clean). The v2 record needs no external resolution: everything
729/// validated lives inside the one record.
730///
731/// Refuses:
732/// - an empty or duplicate source `name`
733///   ([`CapabilityError::EmptySourceName`] /
734///   [`CapabilityError::DuplicateSourceName`]) — names key per-source state;
735/// - a declared `sync` / `verify` operation over a `web` source
736///   ([`CapabilityError::OperationOutOfScope`]);
737/// - a glob `deny_paths` list over a non-path-namespace medium
738///   ([`CapabilityError::GlobDenyIllegal`]);
739/// - any declared source preparation
740///   ([`CapabilityError::PreparationUnsupported`]);
741/// - a declared `coverage_semantics: exhaustive` over a non-enumerable
742///   medium ([`CapabilityError::CoverageExhaustiveUnsupported`]);
743/// - a `prune` block requesting `never-clobber` over a non-base-retrievable
744///   medium ([`CapabilityError::PruneGuaranteeUnsupported`], F1).
745pub fn validate_binding(binding: &Binding) -> Result<(), Vec<CapabilityError>> {
746    let mut refusals = Vec::new();
747    let has_deny = !binding.deny_paths.is_empty();
748    let sync_declared = binding.operations.sync.is_some();
749    let verify_declared = binding.operations.verify.is_some();
750    // F1: a `prune` block requesting `never-clobber` needs a base-retrievable
751    // medium on every source; refuse per-source where it cannot be honoured.
752    let requested_prune = binding
753        .prune
754        .as_ref()
755        .map(|p| p.guarantee)
756        .filter(|g| *g == PruneGuarantee::NeverClobber);
757
758    let mut seen_names: Vec<&str> = Vec::new();
759    for source in &binding.sources {
760        if source.name.is_empty() {
761            refusals.push(CapabilityError::EmptySourceName);
762        } else if seen_names.contains(&source.name.as_str()) {
763            refusals.push(CapabilityError::DuplicateSourceName {
764                name: source.name.clone(),
765            });
766        } else {
767            seen_names.push(&source.name);
768        }
769
770        let caps = medium_capabilities(source.medium_type);
771        let medium_type = serde_json::to_value(source.medium_type)
772            .ok()
773            .and_then(|v| v.as_str().map(str::to_string))
774            .unwrap_or_default();
775
776        // A declared preparation is always unsupported (no implementation).
777        if let Some(prep) = &source.preparation {
778            refusals.push(CapabilityError::PreparationUnsupported {
779                source_name: source.name.clone(),
780                preparation: prep.clone(),
781                impl_version: PREPARATION_IMPL_VERSION,
782            });
783        }
784
785        // sync / verify over a medium with no change signal (web) is out of scope.
786        if !caps.change_signal {
787            for (declared, op) in [
788                (sync_declared, Operation::Sync),
789                (verify_declared, Operation::Verify),
790            ] {
791                if declared {
792                    refusals.push(CapabilityError::OperationOutOfScope {
793                        operation: op.name(),
794                        source_name: source.name.clone(),
795                        medium_type: medium_type.clone(),
796                    });
797                }
798            }
799        }
800
801        // A scope rule must be one its medium's namespace can express. The
802        // engine used to accept any string here and interpret none of them,
803        // so `**/*` scaffolded onto a graph facet looked like scope and
804        // selected nothing. Refuse the undefined form at declaration.
805        //
806        // Checked for every medium whose namespace is not path-shaped, not for
807        // graph alone: `web` has no selector vocabulary either, and gating on
808        // one medium is how the class survived a round — fixed where it had
809        // been demonstrated and left standing one row over.
810        match source.medium_type {
811            MediumType::Graph => {
812                for rule in &source.scope {
813                    if crate::ingest::cursor::parse_entity_selector(&rule.path).is_none() {
814                        refusals.push(CapabilityError::GraphScopeNotEntitySelector {
815                            source_name: source.name.clone(),
816                            pattern: rule.path.clone(),
817                        });
818                    }
819                }
820            }
821            MediumType::Web => {
822                for rule in &source.scope {
823                    refusals.push(CapabilityError::ScopeNotInterpretable {
824                        source_name: source.name.clone(),
825                        pattern: rule.path.clone(),
826                        medium_type: medium_type.clone(),
827                    });
828                }
829            }
830            MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {}
831        }
832
833        // Glob deny_paths over a non-path-shaped namespace is illegal.
834        if has_deny && !caps.glob_deny_legal {
835            refusals.push(CapabilityError::GlobDenyIllegal {
836                source_name: source.name.clone(),
837                medium_type: medium_type.clone(),
838                anchor_namespace: caps.anchor_namespace,
839            });
840        }
841
842        // A declared `exhaustive` over a non-enumerable medium is refused —
843        // the engine cannot compute S(D) there, so the claim is unassertable.
844        // Fires only on what the author actually wrote (`Some(Exhaustive)`);
845        // an undeclared field resolves per medium instead of refusing.
846        if binding.coverage_semantics == Some(CoverageSemantics::Exhaustive) && !caps.enumerable {
847            refusals.push(CapabilityError::CoverageExhaustiveUnsupported {
848                source_name: source.name.clone(),
849                medium_type: medium_type.clone(),
850            });
851        }
852
853        // F1: requested `never-clobber` prune over a non-base-retrievable medium
854        // is refused with the downgrade remedy — at validation, not run time.
855        if requested_prune.is_some() && !caps.base_version_retrievable {
856            refusals.push(CapabilityError::PruneGuaranteeUnsupported {
857                source_name: source.name.clone(),
858                medium_type: medium_type.clone(),
859                requested: PruneGuarantee::NeverClobber.as_wire(),
860                supported: prune_guarantee_for_medium(source.medium_type).as_wire(),
861            });
862        }
863    }
864
865    if refusals.is_empty() {
866        Ok(())
867    } else {
868        Err(refusals)
869    }
870}
871
872/// What a caller wants scaffolded: one binding over one source. Everything
873/// else — deny defaults, the capability-matrix filter, the prune block — is
874/// the engine's to decide, so that every front door that scaffolds a binding
875/// scaffolds the same one.
876#[derive(Debug, Clone)]
877pub struct ScaffoldParams<'a> {
878    /// The mem the binding writes into — the `<mem>` half of the binding id.
879    pub destination_mem: &'a str,
880    /// The single source's `name` (unique within the record; keys per-source
881    /// state). Conventionally the binding stem.
882    pub source_name: &'a str,
883    /// The medium pointer — workspace-relative path, mem id, or URL.
884    pub pointer: &'a str,
885    /// The medium type, which decides the capability matrix.
886    pub medium_type: MediumType,
887    /// Intent prose for the agent, or `None`.
888    pub intent: Option<String>,
889    /// Deny globs to add beyond [`DEFAULT_SCAFFOLD_DENY_PATHS`], for a caller
890    /// that knows something about the tree the engine cannot infer. Materialised
891    /// into the record exactly like the defaults — visible, editable, deletable.
892    /// Engine state and mount storage locations never belong here: their
893    /// exclusion is unconditional in the strategy layer.
894    pub additional_deny_paths: Vec<String>,
895}
896
897/// A scaffolded binding, ready to write: the record, the operations it ended
898/// up declaring, and the warnings the caller must surface.
899#[derive(Debug, Clone)]
900pub struct ScaffoldedBinding {
901    /// The record to write.
902    pub binding: Binding,
903    /// The operation names the record declares, in `build, sync, verify` order
904    /// — the matrix may have stripped some.
905    pub operations: Vec<&'static str>,
906    /// Capability refusals the scaffold resolved by stripping an operation,
907    /// rendered for the caller's output. Never a failure: a scaffold that
908    /// declares less than asked says so rather than refusing.
909    pub warnings: Vec<String>,
910}
911
912/// Scaffold the default binding record for one source — the single
913/// definition of "a fresh binding", shared by every front door that creates
914/// one (`memstead projection init`, the guided `memstead quickstart` path,
915/// any embedder).
916///
917/// The record: one inline [`Source`] scoped `**/*` (a scoped default — an
918/// unscoped source refuses at run time), the enumerable-medium deny defaults
919/// materialised into the record (see [`DEFAULT_SCAFFOLD_DENY_PATHS`] for why
920/// they are recorded rather than injected), unstated `coverage_semantics`
921/// (the scaffold asserts nothing), and `build` + `sync` + `verify` filtered
922/// through the capability matrix — a `web` source loses sync/verify and the
923/// deferral rides `warnings`. Prune is scaffolded wherever sync survived,
924/// with the strongest guarantee the medium supports.
925pub fn scaffold_binding(params: ScaffoldParams<'_>) -> ScaffoldedBinding {
926    let ScaffoldParams {
927        destination_mem,
928        source_name,
929        pointer,
930        medium_type,
931        intent,
932        additional_deny_paths,
933    } = params;
934
935    let source = Source {
936        name: source_name.to_string(),
937        medium_type,
938        pointer: pointer.to_string(),
939        change_detection: None,
940        // Scope is medium-shaped. A path glob over a graph source is not a
941        // narrower scope — it is an uninterpreted string: nothing anywhere
942        // matches globs against entity ids, so `**/*` scaffolded a facet that
943        // looked scoped and selected nothing. The graph namespace gets its own
944        // whole-mem selector; every path medium keeps `**/*` byte-for-byte.
945        //
946        // `web` gets no scope rule at all. Its namespace is `url`, nothing
947        // enumerates it, and no selector vocabulary exists for it — so any
948        // pattern scaffolded here would be decorative in exactly the way the
949        // graph glob was, and the brief would print it at an agent as
950        // selection. An absent scope is the honest scaffold: it renders as
951        // unmonitored rather than as a scope that reaches nothing.
952        scope: match medium_type {
953            MediumType::Graph => vec![PatternEntry {
954                path: "*".to_string(),
955                mode: crate::pipeline::PatternMode::Allow,
956            }],
957            MediumType::Web => Vec::new(),
958            _ => vec![PatternEntry {
959                path: "**/*".to_string(),
960                mode: crate::pipeline::PatternMode::Allow,
961            }],
962        },
963        engagement: None,
964        preparation: None,
965    };
966
967    let mut deny_paths: Vec<String> =
968        if matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
969            DEFAULT_SCAFFOLD_DENY_PATHS
970                .iter()
971                .map(|s| s.to_string())
972                .collect()
973        } else {
974            Vec::new()
975        };
976    for extra in additional_deny_paths {
977        if !deny_paths.contains(&extra) {
978            deny_paths.push(extra);
979        }
980    }
981
982    let mut binding = Binding {
983        version: BINDING_VERSION,
984        intent,
985        sources: vec![source],
986        reference_mems: Vec::new(),
987        destination_mem: destination_mem.to_string(),
988        deny_paths,
989        coverage_semantics: None,
990        rules: None,
991        prune: None,
992        operations: Operations {
993            build: Some(BuildOperation {
994                mode: BuildMode::Discovery,
995                trigger: IngestTrigger::Loop,
996                batch_size: 20,
997                post_actions: None,
998            }),
999            sync: Some(SyncOperation {
1000                trigger: IngestTrigger::Manual,
1001                batch_size: 20,
1002            }),
1003            verify: Some(VerifyOperation {
1004                trigger: IngestTrigger::Manual,
1005                batch_size: 20,
1006                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1007                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1008            }),
1009        },
1010    };
1011
1012    let mut warnings: Vec<String> = Vec::new();
1013    if let Err(refusals) = validate_binding(&binding) {
1014        for r in &refusals {
1015            if let CapabilityError::OperationOutOfScope { operation, .. } = r {
1016                match *operation {
1017                    "sync" => binding.operations.sync = None,
1018                    "verify" => binding.operations.verify = None,
1019                    _ => {}
1020                }
1021            }
1022            warnings.push(r.to_string());
1023        }
1024    }
1025
1026    if binding.operations.sync.is_some() {
1027        binding.prune = Some(PruneConfig {
1028            guarantee: prune_guarantee_for_medium(medium_type),
1029        });
1030    }
1031
1032    let mut operations: Vec<&'static str> = vec!["build"];
1033    if binding.operations.sync.is_some() {
1034        operations.push("sync");
1035    }
1036    if binding.operations.verify.is_some() {
1037        operations.push("verify");
1038    }
1039
1040    ScaffoldedBinding {
1041        binding,
1042        operations,
1043        warnings,
1044    }
1045}
1046
1047#[cfg(test)]
1048mod scaffold_tests {
1049    use super::*;
1050
1051    /// The scaffold is one definition, so every front door writes the same
1052    /// record: scoped source, materialised deny defaults, full operations.
1053    #[test]
1054    fn codebase_scaffold_carries_the_deny_defaults_and_every_operation() {
1055        let s = scaffold_binding(ScaffoldParams {
1056            destination_mem: "app",
1057            source_name: "app",
1058            pointer: ".",
1059            medium_type: MediumType::Codebase,
1060            intent: Some("model it".to_string()),
1061            additional_deny_paths: Vec::new(),
1062        });
1063        assert_eq!(s.operations, vec!["build", "sync", "verify"]);
1064        assert_eq!(s.warnings, Vec::<String>::new());
1065        assert_eq!(s.binding.deny_paths, DEFAULT_SCAFFOLD_DENY_PATHS);
1066        assert_eq!(s.binding.sources[0].scope[0].path, "**/*");
1067        assert_eq!(s.binding.sources[0].pointer, ".");
1068        assert!(s.binding.coverage_semantics.is_none(), "asserts nothing");
1069        assert!(
1070            s.binding.prune.is_some(),
1071            "sync survived, so prune rides it"
1072        );
1073    }
1074
1075    /// A caller's extra deny entries are materialised alongside the
1076    /// defaults — visible, editable, and never silently deduplicated away
1077    /// into a different list.
1078    #[test]
1079    fn additional_deny_paths_are_appended_once() {
1080        let s = scaffold_binding(ScaffoldParams {
1081            destination_mem: "app",
1082            source_name: "app",
1083            pointer: ".",
1084            medium_type: MediumType::Codebase,
1085            intent: None,
1086            additional_deny_paths: vec!["build/**".to_string(), "**/.git/**".to_string()],
1087        });
1088        let expected: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
1089            .iter()
1090            .map(|s| s.to_string())
1091            .chain(std::iter::once("build/**".to_string()))
1092            .collect();
1093        assert_eq!(s.binding.deny_paths, expected);
1094    }
1095
1096    /// A medium the matrix cannot serve loses the operation and says so,
1097    /// rather than scaffolding a record that refuses at run time.
1098    #[test]
1099    fn web_scaffold_loses_sync_and_verify_with_a_warning() {
1100        let s = scaffold_binding(ScaffoldParams {
1101            destination_mem: "app",
1102            source_name: "manual",
1103            pointer: "https://example.com/manual",
1104            medium_type: MediumType::Web,
1105            intent: None,
1106            additional_deny_paths: Vec::new(),
1107        });
1108        assert_eq!(s.operations, vec!["build"]);
1109        assert!(!s.warnings.is_empty(), "the deferral is named");
1110        assert!(s.binding.deny_paths.is_empty(), "no path denies over web");
1111        assert!(s.binding.prune.is_none(), "no sync, no prune");
1112    }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117
1118    /// Scaffolded scope is medium-shaped, and every rule it writes is one
1119    /// something interprets. The path mediums keep `**/*` byte-for-byte;
1120    /// `graph` gets the entity vocabulary; `web` gets NO rule, because its
1121    /// namespace has no selector vocabulary and a pattern there would be
1122    /// decorative in exactly the way the graph glob was — printed at an agent
1123    /// as selection while reaching nothing.
1124    #[test]
1125    fn scaffolded_scope_is_medium_shaped_and_never_decorative() {
1126        let scope_of = |medium: MediumType| {
1127            scaffold_binding(ScaffoldParams {
1128                destination_mem: "m",
1129                source_name: "s",
1130                pointer: "p",
1131                medium_type: medium,
1132                intent: None,
1133                additional_deny_paths: Vec::new(),
1134            })
1135            .binding
1136            .sources[0]
1137                .scope
1138                .iter()
1139                .map(|r| r.path.clone())
1140                .collect::<Vec<_>>()
1141        };
1142
1143        // Unchanged, and asserted so a graph-shaped fix can never drift them.
1144        assert_eq!(scope_of(MediumType::Codebase), vec!["**/*".to_string()]);
1145        assert_eq!(scope_of(MediumType::Filesystem), vec!["**/*".to_string()]);
1146        assert_eq!(scope_of(MediumType::Git), vec!["**/*".to_string()]);
1147
1148        // Entity namespace: a legal selector, and one the run time honours.
1149        assert_eq!(scope_of(MediumType::Graph), vec!["*".to_string()]);
1150        assert!(
1151            crate::ingest::cursor::parse_entity_selector("*").is_some(),
1152            "the graph scaffold writes a selector the parser accepts"
1153        );
1154
1155        // No vocabulary exists, so no rule is written.
1156        assert!(
1157            scope_of(MediumType::Web).is_empty(),
1158            "a web facet carries no scope rather than one nothing interprets"
1159        );
1160    }
1161    use super::*;
1162    use crate::pipeline::PatternMode;
1163
1164    // ---- builders -------------------------------------------------------
1165
1166    fn build_op() -> BuildOperation {
1167        BuildOperation {
1168            mode: BuildMode::Discovery,
1169            trigger: IngestTrigger::Loop,
1170            batch_size: 20,
1171            post_actions: None,
1172        }
1173    }
1174
1175    fn allow(path: &str) -> PatternEntry {
1176        PatternEntry {
1177            path: path.to_string(),
1178            mode: PatternMode::Allow,
1179        }
1180    }
1181
1182    fn source(
1183        name: &str,
1184        medium_type: MediumType,
1185        pointer: &str,
1186        scope: Vec<PatternEntry>,
1187        preparation: Option<&str>,
1188        change_detection: Option<&str>,
1189    ) -> Source {
1190        Source {
1191            name: name.to_string(),
1192            medium_type,
1193            pointer: pointer.to_string(),
1194            change_detection: change_detection.map(str::to_string),
1195            scope,
1196            engagement: None,
1197            preparation: preparation.map(str::to_string),
1198        }
1199    }
1200
1201    fn codebase_source() -> Source {
1202        source(
1203            "source-tree",
1204            MediumType::Codebase,
1205            "../public",
1206            vec![allow("../public/**/*.rs")],
1207            None,
1208            None,
1209        )
1210    }
1211
1212    fn binding() -> Binding {
1213        Binding {
1214            version: BINDING_VERSION,
1215            intent: Some("prose for the agent".to_string()),
1216            sources: vec![codebase_source()],
1217            reference_mems: vec!["engine".to_string()],
1218            destination_mem: "plugin".to_string(),
1219            deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
1220            coverage_semantics: None,
1221            rules: Some(serde_json::json!({ "routing": "…" })),
1222            prune: None,
1223            operations: Operations {
1224                build: Some(build_op()),
1225                sync: Some(SyncOperation {
1226                    trigger: IngestTrigger::Manual,
1227                    batch_size: 20,
1228                }),
1229                verify: Some(VerifyOperation {
1230                    trigger: IngestTrigger::Manual,
1231                    batch_size: 20,
1232                    adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1233                    full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1234                }),
1235            },
1236        }
1237    }
1238
1239    // ---- Binding serde --------------------------------------------------
1240
1241    /// A v2 binding round-trips: serialize → deserialize → equal.
1242    #[test]
1243    fn binding_round_trips() {
1244        let b = binding();
1245        let json = serde_json::to_string(&b).unwrap();
1246        let back: Binding = serde_json::from_str(&json).unwrap();
1247        assert_eq!(back, b);
1248    }
1249
1250    /// The plan's v2 wire example deserializes: inline sources with both
1251    /// halves, the operations block, and coverage semantics as declared.
1252    #[test]
1253    fn plan_shaped_v2_json_deserializes() {
1254        let src = r#"{
1255          "version": 2,
1256          "intent": "prose the building agent reads before every run",
1257          "sources": [
1258            {
1259              "name": "source-tree",
1260              "type": "codebase",
1261              "pointer": "../public",
1262              "change_detection": "auto",
1263              "scope": [
1264                { "path": "../public/**/*.rs", "mode": "allow" },
1265                { "path": "../public/target/**", "mode": "deny" }
1266              ]
1267            }
1268          ],
1269          "reference_mems": ["engineering"],
1270          "destination_mem": "engine",
1271          "deny_paths": ["../dev/**"],
1272          "coverage_semantics": "exhaustive",
1273          "operations": {
1274            "build":  { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1275            "sync":   { "trigger": "loop", "batch_size": 20 },
1276            "verify": { "trigger": "loop", "batch_size": 20,
1277                        "adjudication_cap": 50, "full_resync_every": 20 }
1278          }
1279        }"#;
1280        let b: Binding = serde_json::from_str(src).unwrap();
1281        assert_eq!(b.version, 2);
1282        assert_eq!(b.destination_mem, "engine");
1283        assert_eq!(b.sources.len(), 1);
1284        let s = &b.sources[0];
1285        assert_eq!(s.name, "source-tree");
1286        assert_eq!(s.medium_type, MediumType::Codebase);
1287        assert_eq!(s.pointer, "../public");
1288        assert_eq!(s.change_detection.as_deref(), Some("auto"));
1289        assert_eq!(s.scope.len(), 2);
1290        assert_eq!(b.reference_mems, vec!["engineering".to_string()]);
1291        assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Exhaustive));
1292        assert_eq!(
1293            b.operations.build.as_ref().unwrap().mode,
1294            BuildMode::Discovery
1295        );
1296        assert!(b.operations.sync.is_some());
1297        assert_eq!(b.operations.verify.as_ref().unwrap().adjudication_cap, 50);
1298    }
1299
1300    /// An absent `coverage_semantics` deserializes to `None` ("not
1301    /// stated" — resolved per medium, never a baked-in default), and
1302    /// `one-shot` is the kebab wire form.
1303    #[test]
1304    fn coverage_defaults_and_one_shot_wire_form() {
1305        let src = r#"{
1306          "version": 2,
1307          "destination_mem": "m",
1308          "operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
1309        }"#;
1310        let b: Binding = serde_json::from_str(src).unwrap();
1311        assert_eq!(b.coverage_semantics, None, "absent = not stated");
1312        assert_eq!(
1313            b.operations.build.as_ref().unwrap().mode,
1314            BuildMode::OneShot
1315        );
1316        assert!(b.operations.sync.is_none());
1317        assert!(b.operations.verify.is_none());
1318        // one-shot serializes to the kebab form.
1319        assert_eq!(
1320            serde_json::to_string(&BuildMode::OneShot).unwrap(),
1321            r#""one-shot""#
1322        );
1323    }
1324
1325    /// The tier-3 knobs are additive: a `verify` block without them
1326    /// deserializes to the dogfood-tuned defaults, and a block that sets them
1327    /// round-trips its values.
1328    #[test]
1329    fn verify_tier3_knobs_default_and_round_trip() {
1330        let src = r#"{
1331          "version": 2,
1332          "destination_mem": "m",
1333          "operations": {
1334            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1335            "verify": { "trigger": "manual", "batch_size": 20 }
1336          }
1337        }"#;
1338        let b: Binding = serde_json::from_str(src).unwrap();
1339        let v = b.operations.verify.as_ref().unwrap();
1340        assert_eq!(v.adjudication_cap, DEFAULT_ADJUDICATION_CAP);
1341        assert_eq!(v.full_resync_every, DEFAULT_FULL_RESYNC_EVERY);
1342
1343        // Explicit values round-trip.
1344        let explicit = VerifyOperation {
1345            trigger: IngestTrigger::Manual,
1346            batch_size: 10,
1347            adjudication_cap: 7,
1348            full_resync_every: 3,
1349        };
1350        let json = serde_json::to_string(&explicit).unwrap();
1351        let back: VerifyOperation = serde_json::from_str(&json).unwrap();
1352        assert_eq!(back, explicit);
1353        assert!(json.contains("adjudication_cap"));
1354        assert!(json.contains("full_resync_every"));
1355    }
1356
1357    /// The tier-3 scheduling knobs never change `hash(D)` — they are excluded
1358    /// with the rest of the `verify` block (scheduling never changes the claim).
1359    #[test]
1360    fn tier3_knobs_do_not_change_the_hash() {
1361        let base = hash_binding(&binding());
1362        let mut tuned = binding();
1363        let v = tuned.operations.verify.as_mut().unwrap();
1364        v.adjudication_cap = 999;
1365        v.full_resync_every = 1;
1366        assert_eq!(
1367            base,
1368            hash_binding(&tuned),
1369            "tier-3 verify knobs are excluded from hash(D)"
1370        );
1371    }
1372
1373    /// `"mode": "refinement"` is a deleted value — deserialization fails.
1374    #[test]
1375    fn refinement_mode_is_rejected() {
1376        let src = r#"{
1377          "version": 2,
1378          "destination_mem": "m",
1379          "operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
1380        }"#;
1381        let err = serde_json::from_str::<Binding>(src).unwrap_err();
1382        assert!(
1383            err.to_string().contains("refinement") || err.to_string().contains("unknown variant"),
1384            "unexpected error: {err}"
1385        );
1386    }
1387
1388    /// `version` is required — a projection file without it refuses.
1389    #[test]
1390    fn version_is_required() {
1391        let src = r#"{
1392          "destination_mem": "m",
1393          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1394        }"#;
1395        assert!(serde_json::from_str::<Binding>(src).is_err());
1396    }
1397
1398    // ---- hash(D) --------------------------------------------------------
1399
1400    /// `hash(D)` is stable and recomputable: the same binding hashes
1401    /// identically, and the digest is 64 lowercase hex chars.
1402    #[test]
1403    fn hash_is_stable_and_recomputable() {
1404        let b = binding();
1405        let h1 = hash_binding(&b);
1406        let h2 = hash_binding(&b);
1407        assert_eq!(h1, h2);
1408        assert_eq!(h1.len(), 64);
1409        assert!(
1410            h1.chars()
1411                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1412        );
1413    }
1414
1415    /// Changing a source's selection pattern — now an input *inside* the one
1416    /// record — changes the hash.
1417    #[test]
1418    fn changing_a_source_pattern_changes_the_hash() {
1419        let base = hash_binding(&binding());
1420        let mut changed = binding();
1421        changed.sources[0].scope = vec![allow("../public/**/*.md")];
1422        assert_ne!(base, hash_binding(&changed));
1423    }
1424
1425    /// Changing a source's pointer changes the hash.
1426    #[test]
1427    fn changing_a_source_pointer_changes_the_hash() {
1428        let base = hash_binding(&binding());
1429        let mut changed = binding();
1430        changed.sources[0].pointer = "../elsewhere".to_string();
1431        assert_ne!(base, hash_binding(&changed));
1432    }
1433
1434    /// Changing `trigger`, `batch_size`, or `post_actions` does **not** change
1435    /// the hash — scheduling never changes what the mem claims. Neither does a
1436    /// source's `engagement` contract (the pre-consolidation exclusion carried
1437    /// forward).
1438    #[test]
1439    fn scheduling_knobs_do_not_change_the_hash() {
1440        let base = hash_binding(&binding());
1441
1442        let mut b_trigger = binding();
1443        b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
1444        assert_eq!(base, hash_binding(&b_trigger), "trigger is excluded");
1445
1446        let mut b_batch = binding();
1447        b_batch.operations.build.as_mut().unwrap().batch_size = 999;
1448        assert_eq!(base, hash_binding(&b_batch), "batch_size is excluded");
1449
1450        let mut b_post = binding();
1451        b_post.operations.build.as_mut().unwrap().post_actions =
1452            Some(serde_json::json!({ "archive_source": false }));
1453        assert_eq!(base, hash_binding(&b_post), "post_actions is excluded");
1454
1455        // The sync/verify blocks are excluded too.
1456        let mut b_sync = binding();
1457        b_sync.operations.sync = None;
1458        assert_eq!(base, hash_binding(&b_sync), "sync block is excluded");
1459
1460        // A source's engagement contract is excluded.
1461        let mut b_engage = binding();
1462        b_engage.sources[0].engagement = Some(serde_json::json!({ "readVerb": "Study" }));
1463        assert_eq!(base, hash_binding(&b_engage), "engagement is excluded");
1464    }
1465
1466    /// Changing `operations.build.mode` — a content-defining input — **does**
1467    /// change the hash.
1468    #[test]
1469    fn changing_build_mode_changes_the_hash() {
1470        let base = hash_binding(&binding());
1471        let mut b = binding();
1472        b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
1473        assert_ne!(base, hash_binding(&b));
1474    }
1475
1476    /// An absent `build` block deserializes (serde default) and still hashes —
1477    /// the build mode simply does not participate in `hash(D)`.
1478    #[test]
1479    fn absent_build_deserializes_and_hashes() {
1480        let src = r#"{
1481          "version": 2,
1482          "destination_mem": "m",
1483          "operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
1484        }"#;
1485        let b: Binding = serde_json::from_str(src).unwrap();
1486        assert!(b.operations.build.is_none(), "absent build parses to None");
1487        let h = hash_binding(&b);
1488        assert_eq!(h.len(), 64);
1489    }
1490
1491    // ---- capability matrix + validate -----------------------------------
1492
1493    /// The matrix rows are unchanged by the consolidation.
1494    #[test]
1495    fn capability_matrix_rows() {
1496        let web = medium_capabilities(MediumType::Web);
1497        assert!(!web.enumerable && !web.change_signal && !web.base_version_retrievable);
1498        assert!(!web.glob_deny_legal);
1499        assert_eq!(web.anchor_namespace, "url");
1500
1501        let graph = medium_capabilities(MediumType::Graph);
1502        assert!(graph.enumerable && graph.change_signal && graph.base_version_retrievable);
1503        assert!(!graph.glob_deny_legal, "graph namespace is not path-shaped");
1504        assert_eq!(graph.anchor_namespace, "entity");
1505
1506        for ty in [
1507            MediumType::Codebase,
1508            MediumType::Filesystem,
1509            MediumType::Git,
1510        ] {
1511            let c = medium_capabilities(ty);
1512            assert!(c.enumerable && c.change_signal && c.base_version_retrievable);
1513            assert!(c.glob_deny_legal, "{ty:?} allows glob deny_paths");
1514        }
1515        assert_eq!(
1516            medium_capabilities(MediumType::Git).anchor_namespace,
1517            "path+commit"
1518        );
1519    }
1520
1521    /// An empty source name refuses, and a duplicate source name refuses —
1522    /// per-source state keys must be present and collision-free.
1523    #[test]
1524    fn empty_and_duplicate_source_names_refuse() {
1525        let mut b = binding();
1526        b.deny_paths.clear();
1527        b.sources = vec![
1528            source("", MediumType::Codebase, "../a", vec![], None, None),
1529            source("dup", MediumType::Codebase, "../b", vec![], None, None),
1530            source("dup", MediumType::Codebase, "../c", vec![], None, None),
1531        ];
1532        let errs = validate_binding(&b).unwrap_err();
1533        assert!(
1534            errs.iter()
1535                .any(|e| matches!(e, CapabilityError::EmptySourceName)),
1536            "expected EmptySourceName, got {errs:?}"
1537        );
1538        assert!(
1539            errs.iter().any(|e| matches!(
1540                e,
1541                CapabilityError::DuplicateSourceName { name } if name == "dup"
1542            )),
1543            "expected DuplicateSourceName, got {errs:?}"
1544        );
1545    }
1546
1547    /// `sync` and `verify` over a `web` source each refuse as out-of-scope.
1548    #[test]
1549    fn sync_and_verify_over_web_refuse() {
1550        // Web binding, no deny_paths (globs illegal), no prep — isolate the op refusal.
1551        let mut b = binding();
1552        b.deny_paths.clear();
1553        b.sources = vec![source(
1554            "web-source",
1555            MediumType::Web,
1556            "https://example.com",
1557            vec![],
1558            None,
1559            None,
1560        )];
1561        let errs = validate_binding(&b).unwrap_err();
1562        let ops: Vec<&str> = errs
1563            .iter()
1564            .filter_map(|e| match e {
1565                CapabilityError::OperationOutOfScope { operation, .. } => Some(*operation),
1566                _ => None,
1567            })
1568            .collect();
1569        assert!(ops.contains(&"sync"), "sync refused: {errs:?}");
1570        assert!(ops.contains(&"verify"), "verify refused: {errs:?}");
1571    }
1572
1573    /// Glob `deny_paths` over a `graph` source refuses.
1574    #[test]
1575    fn glob_deny_over_graph_refuses() {
1576        let mut b = binding();
1577        b.operations.sync = None;
1578        b.operations.verify = None;
1579        b.deny_paths = vec!["some/**".to_string()];
1580        b.sources = vec![source(
1581            "graph-source",
1582            MediumType::Graph,
1583            "home",
1584            vec![],
1585            None,
1586            None,
1587        )];
1588        let errs = validate_binding(&b).unwrap_err();
1589        assert!(
1590            errs.iter()
1591                .any(|e| matches!(e, CapabilityError::GlobDenyIllegal { .. })),
1592            "expected GlobDenyIllegal, got {errs:?}"
1593        );
1594    }
1595
1596    /// A declared source preparation refuses at validation time.
1597    #[test]
1598    fn declared_preparation_refuses() {
1599        let mut b = binding();
1600        b.operations.sync = None;
1601        b.operations.verify = None;
1602        b.deny_paths.clear();
1603        b.sources = vec![source(
1604            "manual-pages",
1605            MediumType::Filesystem,
1606            "../docs",
1607            vec![],
1608            Some("pdf-to-markdown"),
1609            None,
1610        )];
1611        let errs = validate_binding(&b).unwrap_err();
1612        assert!(
1613            errs.iter().any(|e| matches!(
1614                e,
1615                CapabilityError::PreparationUnsupported { preparation, .. } if preparation == "pdf-to-markdown"
1616            )),
1617            "expected PreparationUnsupported, got {errs:?}"
1618        );
1619    }
1620
1621    /// Every combination the matrix marks legal validates clean:
1622    /// codebase / filesystem / git / graph bindings with build+sync+verify all
1623    /// pass (graph carries no glob deny_paths, none carry preparation).
1624    #[test]
1625    fn legal_combinations_validate_clean() {
1626        // codebase / filesystem / git — path-shaped, deny_paths legal.
1627        for ty in [
1628            MediumType::Codebase,
1629            MediumType::Filesystem,
1630            MediumType::Git,
1631        ] {
1632            let mut b = binding();
1633            b.sources = vec![source(
1634                "f",
1635                ty,
1636                "../src",
1637                vec![allow("../src/**")],
1638                None,
1639                None,
1640            )];
1641            assert!(
1642                validate_binding(&b).is_ok(),
1643                "{ty:?} build+sync+verify should validate clean"
1644            );
1645        }
1646        // graph — build+sync+verify legal, but only without glob deny_paths.
1647        let mut graph_binding = binding();
1648        graph_binding.deny_paths.clear();
1649        graph_binding.sources = vec![source("g", MediumType::Graph, "home", vec![], None, None)];
1650        assert!(
1651            validate_binding(&graph_binding).is_ok(),
1652            "graph build+sync+verify with no glob deny should validate clean"
1653        );
1654    }
1655
1656    // ---- F1: prune guarantee -------------------------------------------
1657
1658    /// F1 — the `prune` block is additive: a binding without it deserializes
1659    /// to `prune: None`, and a block that sets a guarantee round-trips
1660    /// (defaulting to `conflict-flag` when the guarantee is absent).
1661    #[test]
1662    fn prune_block_is_additive_and_round_trips() {
1663        let src = r#"{
1664          "version": 2,
1665          "destination_mem": "m",
1666          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1667        }"#;
1668        let b: Binding = serde_json::from_str(src).unwrap();
1669        assert!(b.prune.is_none(), "absent prune parses to None");
1670
1671        // A prune block with no guarantee defaults to conflict-flag.
1672        let with_default = r#"{
1673          "version": 2,
1674          "destination_mem": "m",
1675          "prune": {},
1676          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1677        }"#;
1678        let b: Binding = serde_json::from_str(with_default).unwrap();
1679        assert_eq!(
1680            b.prune.as_ref().unwrap().guarantee,
1681            PruneGuarantee::ConflictFlag
1682        );
1683
1684        // Explicit never-clobber round-trips.
1685        let explicit = PruneConfig {
1686            guarantee: PruneGuarantee::NeverClobber,
1687        };
1688        let json = serde_json::to_string(&explicit).unwrap();
1689        assert!(json.contains("never-clobber"));
1690        assert_eq!(
1691            serde_json::from_str::<PruneConfig>(&json).unwrap(),
1692            explicit
1693        );
1694    }
1695
1696    /// F1 — the `prune` policy never changes `hash(D)` (it is a maintenance
1697    /// policy, excluded like the sync/verify blocks).
1698    #[test]
1699    fn prune_does_not_change_the_hash() {
1700        let base = hash_binding(&binding());
1701        let mut pruned = binding();
1702        pruned.prune = Some(PruneConfig {
1703            guarantee: PruneGuarantee::NeverClobber,
1704        });
1705        assert_eq!(
1706            base,
1707            hash_binding(&pruned),
1708            "prune policy is excluded from hash(D)"
1709        );
1710    }
1711
1712    /// F1 — the strongest guarantee a medium supports is base-leg-retrievability:
1713    /// git-backed mediums support never-clobber; `web` supports only conflict-flag.
1714    #[test]
1715    fn prune_guarantee_per_medium_matches_capability_matrix() {
1716        for ty in [
1717            MediumType::Codebase,
1718            MediumType::Filesystem,
1719            MediumType::Git,
1720            MediumType::Graph,
1721        ] {
1722            assert_eq!(
1723                prune_guarantee_for_medium(ty),
1724                PruneGuarantee::NeverClobber,
1725                "{ty:?} can retrieve a base leg → never-clobber"
1726            );
1727        }
1728        assert_eq!(
1729            prune_guarantee_for_medium(MediumType::Web),
1730            PruneGuarantee::ConflictFlag,
1731            "web has no retrievable base leg → conflict-flag only"
1732        );
1733    }
1734
1735    /// F1 REFUSAL — requesting `never-clobber` prune over a `web` source (no
1736    /// retrievable base leg) fails at binding validation with a remedy-bearing
1737    /// error naming the downgrade, never a runtime surprise.
1738    #[test]
1739    fn never_clobber_prune_over_web_refuses_with_remedy() {
1740        let mut b = binding();
1741        b.operations.sync = None; // isolate the prune refusal from op-out-of-scope
1742        b.operations.verify = None;
1743        b.deny_paths.clear();
1744        b.prune = Some(PruneConfig {
1745            guarantee: PruneGuarantee::NeverClobber,
1746        });
1747        b.sources = vec![source(
1748            "web-source",
1749            MediumType::Web,
1750            "https://example.com",
1751            vec![],
1752            None,
1753            None,
1754        )];
1755        let errs = validate_binding(&b).unwrap_err();
1756        let refusal = errs
1757            .iter()
1758            .find_map(|e| match e {
1759                CapabilityError::PruneGuaranteeUnsupported {
1760                    requested,
1761                    supported,
1762                    ..
1763                } => Some((*requested, *supported)),
1764                _ => None,
1765            })
1766            .expect("expected a PruneGuaranteeUnsupported refusal");
1767        assert_eq!(refusal, ("never-clobber", "conflict-flag"));
1768        // The message carries the concrete downgrade remedy.
1769        let msg = errs
1770            .iter()
1771            .find(|e| matches!(e, CapabilityError::PruneGuaranteeUnsupported { .. }))
1772            .unwrap()
1773            .to_string();
1774        assert!(
1775            msg.contains("conflict-flag"),
1776            "remedy names the downgrade: {msg}"
1777        );
1778    }
1779
1780    /// F1 — `never-clobber` over a git-backed source validates clean, and
1781    /// `conflict-flag` (the always-supportable degradation) validates clean over
1782    /// `web` — the guarantee the matrix marks legal is accepted.
1783    #[test]
1784    fn prune_guarantee_supported_validates_clean() {
1785        // never-clobber over codebase — base retrievable, clean.
1786        let mut nc = binding();
1787        nc.prune = Some(PruneConfig {
1788            guarantee: PruneGuarantee::NeverClobber,
1789        });
1790        assert!(validate_binding(&nc).is_ok());
1791
1792        // conflict-flag over web — always supportable (build-only to isolate).
1793        let mut cf = binding();
1794        cf.operations.sync = None;
1795        cf.operations.verify = None;
1796        cf.deny_paths.clear();
1797        cf.prune = Some(PruneConfig {
1798            guarantee: PruneGuarantee::ConflictFlag,
1799        });
1800        cf.sources = vec![source(
1801            "web-source",
1802            MediumType::Web,
1803            "https://example.com",
1804            vec![],
1805            None,
1806            None,
1807        )];
1808        assert!(validate_binding(&cf).is_ok());
1809    }
1810
1811    /// A `web` binding scaffolded build-only (no sync/verify, no deny, no prep)
1812    /// validates clean — the matrix-filtered default.
1813    #[test]
1814    fn web_build_only_validates_clean() {
1815        let mut b = binding();
1816        b.operations.sync = None;
1817        b.operations.verify = None;
1818        b.deny_paths.clear();
1819        b.sources = vec![source(
1820            "web-source",
1821            MediumType::Web,
1822            "https://example.com",
1823            vec![],
1824            None,
1825            None,
1826        )];
1827        assert!(validate_binding(&b).is_ok());
1828    }
1829
1830    // ---- coverage semantics: resolution / refusal / hash stability ------
1831
1832    /// A clean web source carries NO scope: the medium has no scope
1833    /// vocabulary, so any rule on it is uninterpretable and refuses. This
1834    /// helper used to hand out `**/*` — which made every web fixture carry a
1835    /// decorative rule, and is why the defect went unnoticed here.
1836    fn web_source(name: &str) -> Source {
1837        source(
1838            name,
1839            MediumType::Web,
1840            "https://example.test",
1841            vec![],
1842            None,
1843            None,
1844        )
1845    }
1846
1847    /// Resolution: an undeclared field resolves per binding — all
1848    /// sources enumerable → exhaustive; at least one non-enumerable
1849    /// source → curated (a mixed binding claims the weaker of its
1850    /// parts). An explicit `curated` validates over any medium and
1851    /// resolves to curated, declared.
1852    #[test]
1853    fn coverage_resolves_per_medium_when_undeclared() {
1854        let enumerable = binding();
1855        assert_eq!(enumerable.coverage_semantics, None);
1856        let eff = effective_coverage_semantics(&enumerable);
1857        assert_eq!(eff.value, CoverageSemantics::Exhaustive);
1858        assert!(!eff.declared, "resolved, not declared");
1859        validate_binding(&enumerable).expect("undeclared over enumerable validates");
1860
1861        // Mixed: one enumerable + one web source → curated.
1862        let mut mixed = binding();
1863        mixed.sources.push(web_source("front"));
1864        // web has no change signal — drop sync/verify so only coverage
1865        // resolution is under test.
1866        mixed.operations.sync = None;
1867        mixed.operations.verify = None;
1868        mixed.deny_paths.clear();
1869        let eff = effective_coverage_semantics(&mixed);
1870        assert_eq!(eff.value, CoverageSemantics::Curated);
1871        assert!(!eff.declared);
1872        validate_binding(&mixed).expect("undeclared over web validates (resolves, never refuses)");
1873
1874        // Explicit curated over any medium: validates, declared.
1875        let mut curated = mixed.clone();
1876        curated.coverage_semantics = Some(CoverageSemantics::Curated);
1877        validate_binding(&curated).expect("explicit curated validates over any medium");
1878        let eff = effective_coverage_semantics(&curated);
1879        assert_eq!(eff.value, CoverageSemantics::Curated);
1880        assert!(eff.declared);
1881    }
1882
1883    /// Refusal: an explicit `exhaustive` with at least one
1884    /// non-enumerable source refuses, naming the source, the medium,
1885    /// and `curated` as the remedy — alongside other refusals of the
1886    /// same binding, not replacing them. Complements: a binding whose
1887    /// ONLY problem is this one still reports it; an explicit
1888    /// `exhaustive` over enumerable sources is NOT refused.
1889    #[test]
1890    fn explicit_exhaustive_over_non_enumerable_refuses() {
1891        // Only-problem case: clean web binding, explicit exhaustive.
1892        let mut only = binding();
1893        only.sources = vec![web_source("front")];
1894        only.operations.sync = None;
1895        only.operations.verify = None;
1896        only.deny_paths.clear();
1897        only.coverage_semantics = Some(CoverageSemantics::Exhaustive);
1898        let errs = validate_binding(&only).expect_err("must refuse");
1899        assert_eq!(errs.len(), 1, "only this refusal: {errs:?}");
1900        match &errs[0] {
1901            CapabilityError::CoverageExhaustiveUnsupported {
1902                source_name,
1903                medium_type,
1904            } => {
1905                assert_eq!(source_name, "front");
1906                assert_eq!(medium_type, "web");
1907            }
1908            other => panic!("expected CoverageExhaustiveUnsupported, got {other:?}"),
1909        }
1910        let msg = errs[0].to_string();
1911        assert!(
1912            msg.contains("'front'") && msg.contains("'web'") && msg.contains("curated"),
1913            "refusal names source, medium, and the curated remedy: {msg}"
1914        );
1915
1916        // Alongside other refusals: keep sync declared (web has no change
1917        // signal) — both refusals must be reported together.
1918        let mut multi = binding();
1919        multi.sources = vec![web_source("front")];
1920        multi.operations.verify = None;
1921        multi.deny_paths.clear();
1922        multi.coverage_semantics = Some(CoverageSemantics::Exhaustive);
1923        assert!(multi.operations.sync.is_some(), "fixture declares sync");
1924        let errs = validate_binding(&multi).expect_err("must refuse");
1925        assert!(
1926            errs.iter()
1927                .any(|e| matches!(e, CapabilityError::CoverageExhaustiveUnsupported { .. })),
1928            "coverage refusal present: {errs:?}"
1929        );
1930        assert!(
1931            errs.iter()
1932                .any(|e| matches!(e, CapabilityError::OperationOutOfScope { .. })),
1933            "reported alongside the sync refusal, not replacing it: {errs:?}"
1934        );
1935
1936        // Complement: explicit exhaustive over enumerable is NOT refused.
1937        let mut ok = binding();
1938        ok.coverage_semantics = Some(CoverageSemantics::Exhaustive);
1939        validate_binding(&ok).expect("explicit exhaustive over enumerable validates");
1940    }
1941
1942    /// Hash stability: the hash serialises the RESOLVED value, never
1943    /// the `Option`. Over enumerable sources, an undeclared field
1944    /// hashes byte-identically to an explicit `exhaustive` (== the
1945    /// pre-optionality bytes, whose serialized projection was the
1946    /// same `"exhaustive"` value). Over a non-enumerable source, an
1947    /// undeclared field hashes identically to an explicit `curated`
1948    /// (the moved-once, stable-thereafter hash) and differently from
1949    /// the enumerable case's resolution.
1950    #[test]
1951    fn hash_serialises_the_resolved_coverage_value() {
1952        // Enumerable: None == Some(Exhaustive), byte-for-byte.
1953        let undeclared = binding();
1954        let mut declared = binding();
1955        declared.coverage_semantics = Some(CoverageSemantics::Exhaustive);
1956        assert_eq!(
1957            hash_binding(&undeclared),
1958            hash_binding(&declared),
1959            "undeclared over enumerable keeps the pre-optionality hash"
1960        );
1961        // ...and an explicit curated moves it (a genuine coverage change).
1962        let mut curated = binding();
1963        curated.coverage_semantics = Some(CoverageSemantics::Curated);
1964        assert_ne!(hash_binding(&undeclared), hash_binding(&curated));
1965
1966        // Non-enumerable: None == Some(Curated) — the one-time move is
1967        // to the curated hash, stable thereafter.
1968        let mut web_undeclared = binding();
1969        web_undeclared.sources = vec![web_source("front")];
1970        let mut web_curated = web_undeclared.clone();
1971        web_curated.coverage_semantics = Some(CoverageSemantics::Curated);
1972        assert_eq!(
1973            hash_binding(&web_undeclared),
1974            hash_binding(&web_curated),
1975            "undeclared over web resolves (and hashes) as curated"
1976        );
1977    }
1978}