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