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    /// Two sources in the record share a name — per-source state keys would
611    /// collide.
612    #[error(
613        "duplicate source name '{name}': source names are unique within a binding \
614         (they key per-source sync/verify state)"
615    )]
616    DuplicateSourceName {
617        /// The colliding name.
618        name: String,
619    },
620    /// A `sync` / `verify` operation is declared over a medium that cannot
621    /// support it this cycle (a `web` source — operator decision 7). The
622    /// out-of-scope statement is said out loud, never a silent mtime-over-URL.
623    #[error(
624        "operation '{operation}' is out of scope for source '{source_name}' over a '{medium_type}' \
625         medium: this medium has no change signal this cycle (deferred — operator decision 7)"
626    )]
627    OperationOutOfScope {
628        /// The offending operation.
629        operation: &'static str,
630        /// The source declaring it.
631        source_name: String,
632        /// The medium type that cannot support the operation.
633        medium_type: String,
634    },
635    /// A `graph` source's scope carries a pattern the entity-namespace
636    /// vocabulary does not define. Refused at declaration rather than
637    /// silently selecting nothing: a scope that looks like selection but
638    /// reaches nothing is the defect this rule exists to prevent.
639    #[error(
640        "scope pattern '{pattern}' on source '{source_name}' is not a legal entity selector: a \
641         graph medium selects entities, not paths — write '*' for the whole mem, \
642         'type:<entity_type>', or 'id:<glob>'"
643    )]
644    GraphScopeNotEntitySelector {
645        /// The source declaring it.
646        source_name: String,
647        /// The offending pattern, verbatim.
648        pattern: String,
649    },
650    /// A source's scope carries a pattern its medium has no vocabulary to
651    /// express at all, so nothing anywhere can interpret it. Distinct from
652    /// [`Self::GraphScopeNotEntitySelector`], which names the legal forms
653    /// because a legal form exists; here there is none, so the only honest
654    /// scope is no scope.
655    #[error(
656        "scope pattern '{pattern}' on source '{source_name}' cannot be interpreted: a \
657         '{medium_type}' medium has no scope vocabulary, so the pattern would select \
658         nothing while looking like selection — remove the scope rule"
659    )]
660    ScopeNotInterpretable {
661        /// The source declaring it.
662        source_name: String,
663        /// The offending pattern, verbatim.
664        pattern: String,
665        /// The medium with no scope vocabulary.
666        medium_type: String,
667    },
668    /// Glob `deny_paths` are declared over a medium whose namespace is not
669    /// path-shaped (`graph`, `web`) — a glob cannot select in that namespace.
670    #[error(
671        "glob deny_paths are illegal for source '{source_name}' over a '{medium_type}' medium: its \
672         '{anchor_namespace}' namespace is not path-shaped"
673    )]
674    GlobDenyIllegal {
675        /// The offending source.
676        source_name: String,
677        /// The medium type whose namespace is not path-shaped.
678        medium_type: String,
679        /// That medium's anchor namespace.
680        anchor_namespace: &'static str,
681    },
682    /// A source declares a preparation identifier the engine's preparation
683    /// registry ([`crate::preparation`]) does not know. The refusal is
684    /// exactly "not in this engine's registry": a registered identifier
685    /// validates clean, an unknown one refuses, and the message names the
686    /// registered set.
687    ///
688    /// Raised by [`validate_binding`], which the edit/validate paths call —
689    /// NOT `projection init` (which has no `--preparation` flag). The brief
690    /// renderer mirrors the same rule for a record that acquired an unknown
691    /// identifier by hand (accepted at rest, reported unsupported and
692    /// skipped at run time with exit 0; see `GLOSSARY.md` and
693    /// `crate::pipeline::Source::preparation`), so both refusal paths carry
694    /// one semantics and move together.
695    #[error(
696        "source '{source_name}' declares preparation '{preparation}', which is not in this \
697         engine's preparation registry (registered: {}; preparation impl version {impl_version})",
698        crate::preparation::registered_identifiers().join(", ")
699    )]
700    PreparationUnsupported {
701        /// The offending source.
702        source_name: String,
703        /// The declared preparation identifier.
704        preparation: String,
705        /// The current preparation-implementation version.
706        impl_version: u32,
707    },
708    /// A registered preparation is declared over a medium whose anchor
709    /// namespace admits none of the grains it prepares (`entity-load-bearing`
710    /// over a `codebase` source). It would never meet an anchor it applies
711    /// to, so the declaration is refused at validation rather than accepted
712    /// and silently never applying.
713    #[error(
714        "source '{source_name}' declares preparation '{preparation}' over a '{medium_type}' \
715         medium whose '{anchor_namespace}' anchor namespace admits none of the grains it \
716         prepares"
717    )]
718    PreparationGrainMismatch {
719        /// The offending source.
720        source_name: String,
721        /// The declared (registered) preparation identifier.
722        preparation: String,
723        /// The medium type it was declared over.
724        medium_type: String,
725        /// That medium's anchor namespace.
726        anchor_namespace: &'static str,
727    },
728    /// The binding declares `coverage_semantics: exhaustive` while at least
729    /// one source sits on a medium whose scope the engine cannot enumerate
730    /// (`web`) — `S(D)` is not computable, so exhaustive coverage cannot be
731    /// asserted over it. Refused at binding-validation time with `curated`
732    /// as the remedy. An *undeclared* field never trips this: it resolves
733    /// per medium via [`effective_coverage_semantics`].
734    #[error(
735        "coverage_semantics 'exhaustive' is unsupported for source '{source_name}' over a \
736         '{medium_type}' medium: its scope is not enumerable (S(D) is not computable), so \
737         exhaustive coverage cannot be asserted — declare 'curated', or omit the field to \
738         resolve per medium"
739    )]
740    CoverageExhaustiveUnsupported {
741        /// The offending source.
742        source_name: String,
743        /// The medium type whose scope is not enumerable.
744        medium_type: String,
745    },
746    /// The binding requests a `prune` guarantee the source's medium cannot
747    /// support (F1) — `never-clobber` over a medium whose base leg is not
748    /// retrievable (`web`). Refused at binding-validation time with the
749    /// downgrade remedy, never discovered at run time.
750    #[error(
751        "prune guarantee '{requested}' is unsupported for source '{source_name}' over a \
752         '{medium_type}' medium: its base leg is not retrievable, so only '{supported}' \
753         degradation is possible — set the binding's prune guarantee to '{supported}', or \
754         point the source at a git-backed medium"
755    )]
756    PruneGuaranteeUnsupported {
757        /// The offending source.
758        source_name: String,
759        /// The medium type that cannot support the requested guarantee.
760        medium_type: String,
761        /// The requested guarantee wire string.
762        requested: &'static str,
763        /// The strongest guarantee this medium supports (the downgrade remedy).
764        supported: &'static str,
765    },
766}
767
768/// Validate a binding against the medium-capability matrix and the in-record
769/// source rules, returning **every** refusal (empty `Err` never returned —
770/// `Ok` means clean). The v2 record needs no external resolution: everything
771/// validated lives inside the one record.
772///
773/// Refuses:
774/// - an empty or duplicate source `name`
775///   ([`CapabilityError::EmptySourceName`] /
776///   [`CapabilityError::DuplicateSourceName`]) — names key per-source state;
777/// - a declared `sync` / `verify` operation over a `web` source
778///   ([`CapabilityError::OperationOutOfScope`]);
779/// - a glob `deny_paths` list over a non-path-namespace medium
780///   ([`CapabilityError::GlobDenyIllegal`]);
781/// - a source preparation the engine's registry does not know
782///   ([`CapabilityError::PreparationUnsupported`]), or a registered one
783///   over a medium whose anchor namespace admits none of its grains
784///   ([`CapabilityError::PreparationGrainMismatch`]);
785/// - a declared `coverage_semantics: exhaustive` over a non-enumerable
786///   medium ([`CapabilityError::CoverageExhaustiveUnsupported`]);
787/// - a `prune` block requesting `never-clobber` over a non-base-retrievable
788///   medium ([`CapabilityError::PruneGuaranteeUnsupported`], F1).
789pub fn validate_binding(binding: &Binding) -> Result<(), Vec<CapabilityError>> {
790    let mut refusals = Vec::new();
791    let has_deny = !binding.deny_paths.is_empty();
792    let sync_declared = binding.operations.sync.is_some();
793    let verify_declared = binding.operations.verify.is_some();
794    // F1: a `prune` block requesting `never-clobber` needs a base-retrievable
795    // medium on every source; refuse per-source where it cannot be honoured.
796    let requested_prune = binding
797        .prune
798        .as_ref()
799        .map(|p| p.guarantee)
800        .filter(|g| *g == PruneGuarantee::NeverClobber);
801
802    let mut seen_names: Vec<&str> = Vec::new();
803    for source in &binding.sources {
804        if source.name.is_empty() {
805            refusals.push(CapabilityError::EmptySourceName);
806        } else if seen_names.contains(&source.name.as_str()) {
807            refusals.push(CapabilityError::DuplicateSourceName {
808                name: source.name.clone(),
809            });
810        } else {
811            seen_names.push(&source.name);
812        }
813
814        let caps = medium_capabilities(source.medium_type);
815        let medium_type = serde_json::to_value(source.medium_type)
816            .ok()
817            .and_then(|v| v.as_str().map(str::to_string))
818            .unwrap_or_default();
819
820        // A declared preparation must be one the registry knows — the
821        // touchpoints consult the registry by identifier, so an unknown one
822        // could never be applied — and one that can apply over this medium's
823        // anchor namespace.
824        if let Some(prep) = &source.preparation {
825            match crate::preparation::lookup(prep) {
826                None => refusals.push(CapabilityError::PreparationUnsupported {
827                    source_name: source.name.clone(),
828                    preparation: prep.clone(),
829                    impl_version: PREPARATION_IMPL_VERSION,
830                }),
831                Some(registered)
832                    if !crate::preparation::applies_to_namespace(
833                        registered,
834                        caps.anchor_namespace,
835                    ) =>
836                {
837                    refusals.push(CapabilityError::PreparationGrainMismatch {
838                        source_name: source.name.clone(),
839                        preparation: prep.clone(),
840                        medium_type: medium_type.clone(),
841                        anchor_namespace: caps.anchor_namespace,
842                    });
843                }
844                Some(_) => {}
845            }
846        }
847
848        // sync / verify over a medium with no change signal (web) is out of scope.
849        if !caps.change_signal {
850            for (declared, op) in [
851                (sync_declared, Operation::Sync),
852                (verify_declared, Operation::Verify),
853            ] {
854                if declared {
855                    refusals.push(CapabilityError::OperationOutOfScope {
856                        operation: op.name(),
857                        source_name: source.name.clone(),
858                        medium_type: medium_type.clone(),
859                    });
860                }
861            }
862        }
863
864        // A scope rule must be one its medium's namespace can express. The
865        // engine used to accept any string here and interpret none of them,
866        // so `**/*` scaffolded onto a graph facet looked like scope and
867        // selected nothing. Refuse the undefined form at declaration.
868        //
869        // Checked for every medium whose namespace is not path-shaped, not for
870        // graph alone: `web` has no selector vocabulary either, and gating on
871        // one medium is how the class survived a round — fixed where it had
872        // been demonstrated and left standing one row over.
873        match source.medium_type {
874            MediumType::Graph => {
875                for rule in &source.scope {
876                    if crate::ingest::cursor::parse_entity_selector(&rule.path).is_none() {
877                        refusals.push(CapabilityError::GraphScopeNotEntitySelector {
878                            source_name: source.name.clone(),
879                            pattern: rule.path.clone(),
880                        });
881                    }
882                }
883            }
884            MediumType::Web => {
885                for rule in &source.scope {
886                    refusals.push(CapabilityError::ScopeNotInterpretable {
887                        source_name: source.name.clone(),
888                        pattern: rule.path.clone(),
889                        medium_type: medium_type.clone(),
890                    });
891                }
892            }
893            MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {}
894        }
895
896        // Glob deny_paths over a non-path-shaped namespace is illegal.
897        if has_deny && !caps.glob_deny_legal {
898            refusals.push(CapabilityError::GlobDenyIllegal {
899                source_name: source.name.clone(),
900                medium_type: medium_type.clone(),
901                anchor_namespace: caps.anchor_namespace,
902            });
903        }
904
905        // A declared `exhaustive` over a non-enumerable medium is refused —
906        // the engine cannot compute S(D) there, so the claim is unassertable.
907        // Fires only on what the author actually wrote (`Some(Exhaustive)`);
908        // an undeclared field resolves per medium instead of refusing.
909        if binding.coverage_semantics == Some(CoverageSemantics::Exhaustive) && !caps.enumerable {
910            refusals.push(CapabilityError::CoverageExhaustiveUnsupported {
911                source_name: source.name.clone(),
912                medium_type: medium_type.clone(),
913            });
914        }
915
916        // F1: requested `never-clobber` prune over a non-base-retrievable medium
917        // is refused with the downgrade remedy — at validation, not run time.
918        if requested_prune.is_some() && !caps.base_version_retrievable {
919            refusals.push(CapabilityError::PruneGuaranteeUnsupported {
920                source_name: source.name.clone(),
921                medium_type: medium_type.clone(),
922                requested: PruneGuarantee::NeverClobber.as_wire(),
923                supported: prune_guarantee_for_medium(source.medium_type).as_wire(),
924            });
925        }
926    }
927
928    if refusals.is_empty() {
929        Ok(())
930    } else {
931        Err(refusals)
932    }
933}
934
935/// What a caller wants scaffolded: one binding over one source. Everything
936/// else — deny defaults, the capability-matrix filter, the prune block — is
937/// the engine's to decide, so that every front door that scaffolds a binding
938/// scaffolds the same one.
939#[derive(Debug, Clone)]
940pub struct ScaffoldParams<'a> {
941    /// The mem the binding writes into — the `<mem>` half of the binding id.
942    pub destination_mem: &'a str,
943    /// The single source's `name` (unique within the record; keys per-source
944    /// state). Conventionally the binding stem.
945    pub source_name: &'a str,
946    /// The medium pointer — workspace-relative path, mem id, or URL.
947    pub pointer: &'a str,
948    /// The medium type, which decides the capability matrix.
949    pub medium_type: MediumType,
950    /// Intent prose for the agent, or `None`.
951    pub intent: Option<String>,
952    /// Deny globs to add beyond [`DEFAULT_SCAFFOLD_DENY_PATHS`], for a caller
953    /// that knows something about the tree the engine cannot infer. Materialised
954    /// into the record exactly like the defaults — visible, editable, deletable.
955    /// Engine state and mount storage locations never belong here: their
956    /// exclusion is unconditional in the strategy layer.
957    pub additional_deny_paths: Vec<String>,
958}
959
960/// A scaffolded binding, ready to write: the record, the operations it ended
961/// up declaring, and the warnings the caller must surface.
962#[derive(Debug, Clone)]
963pub struct ScaffoldedBinding {
964    /// The record to write.
965    pub binding: Binding,
966    /// The operation names the record declares, in `build, sync, verify` order
967    /// — the matrix may have stripped some.
968    pub operations: Vec<&'static str>,
969    /// Capability refusals the scaffold resolved by stripping an operation,
970    /// rendered for the caller's output. Never a failure: a scaffold that
971    /// declares less than asked says so rather than refusing.
972    pub warnings: Vec<String>,
973}
974
975/// Scaffold the default binding record for one source — the single
976/// definition of "a fresh binding", shared by every front door that creates
977/// one (`memstead projection init`, the guided `memstead quickstart` path,
978/// any embedder).
979///
980/// The record: one inline [`Source`] scoped `**/*` (a scoped default — an
981/// unscoped source refuses at run time), the enumerable-medium deny defaults
982/// materialised into the record (see [`DEFAULT_SCAFFOLD_DENY_PATHS`] for why
983/// they are recorded rather than injected), unstated `coverage_semantics`
984/// (the scaffold asserts nothing), and `build` + `sync` + `verify` filtered
985/// through the capability matrix — a `web` source loses sync/verify and the
986/// deferral rides `warnings`. Prune is scaffolded wherever sync survived,
987/// with the strongest guarantee the medium supports.
988pub fn scaffold_binding(params: ScaffoldParams<'_>) -> ScaffoldedBinding {
989    let ScaffoldParams {
990        destination_mem,
991        source_name,
992        pointer,
993        medium_type,
994        intent,
995        additional_deny_paths,
996    } = params;
997
998    let source = Source {
999        name: source_name.to_string(),
1000        medium_type,
1001        pointer: pointer.to_string(),
1002        change_detection: None,
1003        // Scope is medium-shaped. A path glob over a graph source is not a
1004        // narrower scope — it is an uninterpreted string: nothing anywhere
1005        // matches globs against entity ids, so `**/*` scaffolded a facet that
1006        // looked scoped and selected nothing. The graph namespace gets its own
1007        // whole-mem selector; every path medium keeps `**/*` byte-for-byte.
1008        //
1009        // `web` gets no scope rule at all. Its namespace is `url`, nothing
1010        // enumerates it, and no selector vocabulary exists for it — so any
1011        // pattern scaffolded here would be decorative in exactly the way the
1012        // graph glob was, and the brief would print it at an agent as
1013        // selection. An absent scope is the honest scaffold: it renders as
1014        // unmonitored rather than as a scope that reaches nothing.
1015        scope: match medium_type {
1016            MediumType::Graph => vec![PatternEntry {
1017                path: "*".to_string(),
1018                mode: crate::pipeline::PatternMode::Allow,
1019            }],
1020            MediumType::Web => Vec::new(),
1021            _ => vec![PatternEntry {
1022                path: "**/*".to_string(),
1023                mode: crate::pipeline::PatternMode::Allow,
1024            }],
1025        },
1026        engagement: None,
1027        preparation: None,
1028    };
1029
1030    let mut deny_paths: Vec<String> =
1031        if matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
1032            DEFAULT_SCAFFOLD_DENY_PATHS
1033                .iter()
1034                .map(|s| s.to_string())
1035                .collect()
1036        } else {
1037            Vec::new()
1038        };
1039    for extra in additional_deny_paths {
1040        if !deny_paths.contains(&extra) {
1041            deny_paths.push(extra);
1042        }
1043    }
1044
1045    let mut binding = Binding {
1046        version: BINDING_VERSION,
1047        intent,
1048        sources: vec![source],
1049        reference_mems: Vec::new(),
1050        destination_mem: destination_mem.to_string(),
1051        deny_paths,
1052        coverage_semantics: None,
1053        rules: None,
1054        prune: None,
1055        operations: Operations {
1056            build: Some(BuildOperation {
1057                mode: BuildMode::Discovery,
1058                trigger: IngestTrigger::Loop,
1059                batch_size: 20,
1060                post_actions: None,
1061            }),
1062            sync: Some(SyncOperation {
1063                trigger: IngestTrigger::Manual,
1064                batch_size: 20,
1065            }),
1066            verify: Some(VerifyOperation {
1067                trigger: IngestTrigger::Manual,
1068                batch_size: 20,
1069                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1070                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1071            }),
1072        },
1073    };
1074
1075    let mut warnings: Vec<String> = Vec::new();
1076    if let Err(refusals) = validate_binding(&binding) {
1077        for r in &refusals {
1078            if let CapabilityError::OperationOutOfScope { operation, .. } = r {
1079                match *operation {
1080                    "sync" => binding.operations.sync = None,
1081                    "verify" => binding.operations.verify = None,
1082                    _ => {}
1083                }
1084            }
1085            warnings.push(r.to_string());
1086        }
1087    }
1088
1089    if binding.operations.sync.is_some() {
1090        binding.prune = Some(PruneConfig {
1091            guarantee: prune_guarantee_for_medium(medium_type),
1092        });
1093    }
1094
1095    let mut operations: Vec<&'static str> = vec!["build"];
1096    if binding.operations.sync.is_some() {
1097        operations.push("sync");
1098    }
1099    if binding.operations.verify.is_some() {
1100        operations.push("verify");
1101    }
1102
1103    ScaffoldedBinding {
1104        binding,
1105        operations,
1106        warnings,
1107    }
1108}
1109
1110#[cfg(test)]
1111mod scaffold_tests {
1112    use super::*;
1113
1114    /// The scaffold is one definition, so every front door writes the same
1115    /// record: scoped source, materialised deny defaults, full operations.
1116    #[test]
1117    fn codebase_scaffold_carries_the_deny_defaults_and_every_operation() {
1118        let s = scaffold_binding(ScaffoldParams {
1119            destination_mem: "app",
1120            source_name: "app",
1121            pointer: ".",
1122            medium_type: MediumType::Codebase,
1123            intent: Some("model it".to_string()),
1124            additional_deny_paths: Vec::new(),
1125        });
1126        assert_eq!(s.operations, vec!["build", "sync", "verify"]);
1127        assert_eq!(s.warnings, Vec::<String>::new());
1128        assert_eq!(s.binding.deny_paths, DEFAULT_SCAFFOLD_DENY_PATHS);
1129        assert_eq!(s.binding.sources[0].scope[0].path, "**/*");
1130        assert_eq!(s.binding.sources[0].pointer, ".");
1131        assert!(s.binding.coverage_semantics.is_none(), "asserts nothing");
1132        assert!(
1133            s.binding.prune.is_some(),
1134            "sync survived, so prune rides it"
1135        );
1136    }
1137
1138    /// A caller's extra deny entries are materialised alongside the
1139    /// defaults — visible, editable, and never silently deduplicated away
1140    /// into a different list.
1141    #[test]
1142    fn additional_deny_paths_are_appended_once() {
1143        let s = scaffold_binding(ScaffoldParams {
1144            destination_mem: "app",
1145            source_name: "app",
1146            pointer: ".",
1147            medium_type: MediumType::Codebase,
1148            intent: None,
1149            additional_deny_paths: vec!["build/**".to_string(), "**/.git/**".to_string()],
1150        });
1151        let expected: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
1152            .iter()
1153            .map(|s| s.to_string())
1154            .chain(std::iter::once("build/**".to_string()))
1155            .collect();
1156        assert_eq!(s.binding.deny_paths, expected);
1157    }
1158
1159    /// A medium the matrix cannot serve loses the operation and says so,
1160    /// rather than scaffolding a record that refuses at run time.
1161    #[test]
1162    fn web_scaffold_loses_sync_and_verify_with_a_warning() {
1163        let s = scaffold_binding(ScaffoldParams {
1164            destination_mem: "app",
1165            source_name: "manual",
1166            pointer: "https://example.com/manual",
1167            medium_type: MediumType::Web,
1168            intent: None,
1169            additional_deny_paths: Vec::new(),
1170        });
1171        assert_eq!(s.operations, vec!["build"]);
1172        assert!(!s.warnings.is_empty(), "the deferral is named");
1173        assert!(s.binding.deny_paths.is_empty(), "no path denies over web");
1174        assert!(s.binding.prune.is_none(), "no sync, no prune");
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180
1181    /// Scaffolded scope is medium-shaped, and every rule it writes is one
1182    /// something interprets. The path mediums keep `**/*` byte-for-byte;
1183    /// `graph` gets the entity vocabulary; `web` gets NO rule, because its
1184    /// namespace has no selector vocabulary and a pattern there would be
1185    /// decorative in exactly the way the graph glob was — printed at an agent
1186    /// as selection while reaching nothing.
1187    #[test]
1188    fn scaffolded_scope_is_medium_shaped_and_never_decorative() {
1189        let scope_of = |medium: MediumType| {
1190            scaffold_binding(ScaffoldParams {
1191                destination_mem: "m",
1192                source_name: "s",
1193                pointer: "p",
1194                medium_type: medium,
1195                intent: None,
1196                additional_deny_paths: Vec::new(),
1197            })
1198            .binding
1199            .sources[0]
1200                .scope
1201                .iter()
1202                .map(|r| r.path.clone())
1203                .collect::<Vec<_>>()
1204        };
1205
1206        // Unchanged, and asserted so a graph-shaped fix can never drift them.
1207        assert_eq!(scope_of(MediumType::Codebase), vec!["**/*".to_string()]);
1208        assert_eq!(scope_of(MediumType::Filesystem), vec!["**/*".to_string()]);
1209        assert_eq!(scope_of(MediumType::Git), vec!["**/*".to_string()]);
1210
1211        // Entity namespace: a legal selector, and one the run time honours.
1212        assert_eq!(scope_of(MediumType::Graph), vec!["*".to_string()]);
1213        assert!(
1214            crate::ingest::cursor::parse_entity_selector("*").is_some(),
1215            "the graph scaffold writes a selector the parser accepts"
1216        );
1217
1218        // No vocabulary exists, so no rule is written.
1219        assert!(
1220            scope_of(MediumType::Web).is_empty(),
1221            "a web facet carries no scope rather than one nothing interprets"
1222        );
1223    }
1224    use super::*;
1225    use crate::pipeline::PatternMode;
1226
1227    // ---- builders -------------------------------------------------------
1228
1229    fn build_op() -> BuildOperation {
1230        BuildOperation {
1231            mode: BuildMode::Discovery,
1232            trigger: IngestTrigger::Loop,
1233            batch_size: 20,
1234            post_actions: None,
1235        }
1236    }
1237
1238    fn allow(path: &str) -> PatternEntry {
1239        PatternEntry {
1240            path: path.to_string(),
1241            mode: PatternMode::Allow,
1242        }
1243    }
1244
1245    fn source(
1246        name: &str,
1247        medium_type: MediumType,
1248        pointer: &str,
1249        scope: Vec<PatternEntry>,
1250        preparation: Option<&str>,
1251        change_detection: Option<&str>,
1252    ) -> Source {
1253        Source {
1254            name: name.to_string(),
1255            medium_type,
1256            pointer: pointer.to_string(),
1257            change_detection: change_detection.map(str::to_string),
1258            scope,
1259            engagement: None,
1260            preparation: preparation.map(str::to_string),
1261        }
1262    }
1263
1264    fn codebase_source() -> Source {
1265        source(
1266            "source-tree",
1267            MediumType::Codebase,
1268            "../public",
1269            vec![allow("../public/**/*.rs")],
1270            None,
1271            None,
1272        )
1273    }
1274
1275    fn binding() -> Binding {
1276        Binding {
1277            version: BINDING_VERSION,
1278            intent: Some("prose for the agent".to_string()),
1279            sources: vec![codebase_source()],
1280            reference_mems: vec!["engine".to_string()],
1281            destination_mem: "plugin".to_string(),
1282            deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
1283            coverage_semantics: None,
1284            rules: Some(serde_json::json!({ "routing": "…" })),
1285            prune: None,
1286            operations: Operations {
1287                build: Some(build_op()),
1288                sync: Some(SyncOperation {
1289                    trigger: IngestTrigger::Manual,
1290                    batch_size: 20,
1291                }),
1292                verify: Some(VerifyOperation {
1293                    trigger: IngestTrigger::Manual,
1294                    batch_size: 20,
1295                    adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1296                    full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1297                }),
1298            },
1299        }
1300    }
1301
1302    // ---- Binding serde --------------------------------------------------
1303
1304    /// A v2 binding round-trips: serialize → deserialize → equal.
1305    #[test]
1306    fn binding_round_trips() {
1307        let b = binding();
1308        let json = serde_json::to_string(&b).unwrap();
1309        let back: Binding = serde_json::from_str(&json).unwrap();
1310        assert_eq!(back, b);
1311    }
1312
1313    /// The plan's v2 wire example deserializes: inline sources with both
1314    /// halves, the operations block, and coverage semantics as declared.
1315    #[test]
1316    fn plan_shaped_v2_json_deserializes() {
1317        let src = r#"{
1318          "version": 2,
1319          "intent": "prose the building agent reads before every run",
1320          "sources": [
1321            {
1322              "name": "source-tree",
1323              "type": "codebase",
1324              "pointer": "../public",
1325              "change_detection": "auto",
1326              "scope": [
1327                { "path": "../public/**/*.rs", "mode": "allow" },
1328                { "path": "../public/target/**", "mode": "deny" }
1329              ]
1330            }
1331          ],
1332          "reference_mems": ["engineering"],
1333          "destination_mem": "engine",
1334          "deny_paths": ["../dev/**"],
1335          "coverage_semantics": "exhaustive",
1336          "operations": {
1337            "build":  { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1338            "sync":   { "trigger": "loop", "batch_size": 20 },
1339            "verify": { "trigger": "loop", "batch_size": 20,
1340                        "adjudication_cap": 50, "full_resync_every": 20 }
1341          }
1342        }"#;
1343        let b: Binding = serde_json::from_str(src).unwrap();
1344        assert_eq!(b.version, 2);
1345        assert_eq!(b.destination_mem, "engine");
1346        assert_eq!(b.sources.len(), 1);
1347        let s = &b.sources[0];
1348        assert_eq!(s.name, "source-tree");
1349        assert_eq!(s.medium_type, MediumType::Codebase);
1350        assert_eq!(s.pointer, "../public");
1351        assert_eq!(s.change_detection.as_deref(), Some("auto"));
1352        assert_eq!(s.scope.len(), 2);
1353        assert_eq!(b.reference_mems, vec!["engineering".to_string()]);
1354        assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Exhaustive));
1355        assert_eq!(
1356            b.operations.build.as_ref().unwrap().mode,
1357            BuildMode::Discovery
1358        );
1359        assert!(b.operations.sync.is_some());
1360        assert_eq!(b.operations.verify.as_ref().unwrap().adjudication_cap, 50);
1361    }
1362
1363    /// An absent `coverage_semantics` deserializes to `None` ("not
1364    /// stated" — resolved per medium, never a baked-in default), and
1365    /// `one-shot` is the kebab wire form.
1366    #[test]
1367    fn coverage_defaults_and_one_shot_wire_form() {
1368        let src = r#"{
1369          "version": 2,
1370          "destination_mem": "m",
1371          "operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
1372        }"#;
1373        let b: Binding = serde_json::from_str(src).unwrap();
1374        assert_eq!(b.coverage_semantics, None, "absent = not stated");
1375        assert_eq!(
1376            b.operations.build.as_ref().unwrap().mode,
1377            BuildMode::OneShot
1378        );
1379        assert!(b.operations.sync.is_none());
1380        assert!(b.operations.verify.is_none());
1381        // one-shot serializes to the kebab form.
1382        assert_eq!(
1383            serde_json::to_string(&BuildMode::OneShot).unwrap(),
1384            r#""one-shot""#
1385        );
1386    }
1387
1388    /// The tier-3 knobs are additive: a `verify` block without them
1389    /// deserializes to the dogfood-tuned defaults, and a block that sets them
1390    /// round-trips its values.
1391    #[test]
1392    fn verify_tier3_knobs_default_and_round_trip() {
1393        let src = r#"{
1394          "version": 2,
1395          "destination_mem": "m",
1396          "operations": {
1397            "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1398            "verify": { "trigger": "manual", "batch_size": 20 }
1399          }
1400        }"#;
1401        let b: Binding = serde_json::from_str(src).unwrap();
1402        let v = b.operations.verify.as_ref().unwrap();
1403        assert_eq!(v.adjudication_cap, DEFAULT_ADJUDICATION_CAP);
1404        assert_eq!(v.full_resync_every, DEFAULT_FULL_RESYNC_EVERY);
1405
1406        // Explicit values round-trip.
1407        let explicit = VerifyOperation {
1408            trigger: IngestTrigger::Manual,
1409            batch_size: 10,
1410            adjudication_cap: 7,
1411            full_resync_every: 3,
1412        };
1413        let json = serde_json::to_string(&explicit).unwrap();
1414        let back: VerifyOperation = serde_json::from_str(&json).unwrap();
1415        assert_eq!(back, explicit);
1416        assert!(json.contains("adjudication_cap"));
1417        assert!(json.contains("full_resync_every"));
1418    }
1419
1420    /// The tier-3 scheduling knobs never change `hash(D)` — they are excluded
1421    /// with the rest of the `verify` block (scheduling never changes the claim).
1422    #[test]
1423    fn tier3_knobs_do_not_change_the_hash() {
1424        let base = hash_binding(&binding());
1425        let mut tuned = binding();
1426        let v = tuned.operations.verify.as_mut().unwrap();
1427        v.adjudication_cap = 999;
1428        v.full_resync_every = 1;
1429        assert_eq!(
1430            base,
1431            hash_binding(&tuned),
1432            "tier-3 verify knobs are excluded from hash(D)"
1433        );
1434    }
1435
1436    /// `"mode": "refinement"` is a deleted value — deserialization fails.
1437    #[test]
1438    fn refinement_mode_is_rejected() {
1439        let src = r#"{
1440          "version": 2,
1441          "destination_mem": "m",
1442          "operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
1443        }"#;
1444        let err = serde_json::from_str::<Binding>(src).unwrap_err();
1445        assert!(
1446            err.to_string().contains("refinement") || err.to_string().contains("unknown variant"),
1447            "unexpected error: {err}"
1448        );
1449    }
1450
1451    /// `version` is required — a projection file without it refuses.
1452    #[test]
1453    fn version_is_required() {
1454        let src = r#"{
1455          "destination_mem": "m",
1456          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1457        }"#;
1458        assert!(serde_json::from_str::<Binding>(src).is_err());
1459    }
1460
1461    // ---- hash(D) --------------------------------------------------------
1462
1463    /// `hash(D)` is stable and recomputable: the same binding hashes
1464    /// identically, and the digest is 64 lowercase hex chars.
1465    #[test]
1466    fn hash_is_stable_and_recomputable() {
1467        let b = binding();
1468        let h1 = hash_binding(&b);
1469        let h2 = hash_binding(&b);
1470        assert_eq!(h1, h2);
1471        assert_eq!(h1.len(), 64);
1472        assert!(
1473            h1.chars()
1474                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1475        );
1476    }
1477
1478    /// Changing a source's selection pattern — now an input *inside* the one
1479    /// record — changes the hash.
1480    #[test]
1481    fn changing_a_source_pattern_changes_the_hash() {
1482        let base = hash_binding(&binding());
1483        let mut changed = binding();
1484        changed.sources[0].scope = vec![allow("../public/**/*.md")];
1485        assert_ne!(base, hash_binding(&changed));
1486    }
1487
1488    /// Changing a source's pointer changes the hash.
1489    #[test]
1490    fn changing_a_source_pointer_changes_the_hash() {
1491        let base = hash_binding(&binding());
1492        let mut changed = binding();
1493        changed.sources[0].pointer = "../elsewhere".to_string();
1494        assert_ne!(base, hash_binding(&changed));
1495    }
1496
1497    /// Changing `trigger`, `batch_size`, or `post_actions` does **not** change
1498    /// the hash — scheduling never changes what the mem claims. Neither does a
1499    /// source's `engagement` contract (the pre-consolidation exclusion carried
1500    /// forward).
1501    #[test]
1502    fn scheduling_knobs_do_not_change_the_hash() {
1503        let base = hash_binding(&binding());
1504
1505        let mut b_trigger = binding();
1506        b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
1507        assert_eq!(base, hash_binding(&b_trigger), "trigger is excluded");
1508
1509        let mut b_batch = binding();
1510        b_batch.operations.build.as_mut().unwrap().batch_size = 999;
1511        assert_eq!(base, hash_binding(&b_batch), "batch_size is excluded");
1512
1513        let mut b_post = binding();
1514        b_post.operations.build.as_mut().unwrap().post_actions =
1515            Some(serde_json::json!({ "archive_source": false }));
1516        assert_eq!(base, hash_binding(&b_post), "post_actions is excluded");
1517
1518        // The sync/verify blocks are excluded too.
1519        let mut b_sync = binding();
1520        b_sync.operations.sync = None;
1521        assert_eq!(base, hash_binding(&b_sync), "sync block is excluded");
1522
1523        // A source's engagement contract is excluded.
1524        let mut b_engage = binding();
1525        b_engage.sources[0].engagement = Some(serde_json::json!({ "readVerb": "Study" }));
1526        assert_eq!(base, hash_binding(&b_engage), "engagement is excluded");
1527    }
1528
1529    /// Changing `operations.build.mode` — a content-defining input — **does**
1530    /// change the hash.
1531    #[test]
1532    fn changing_build_mode_changes_the_hash() {
1533        let base = hash_binding(&binding());
1534        let mut b = binding();
1535        b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
1536        assert_ne!(base, hash_binding(&b));
1537    }
1538
1539    /// An absent `build` block deserializes (serde default) and still hashes —
1540    /// the build mode simply does not participate in `hash(D)`.
1541    #[test]
1542    fn absent_build_deserializes_and_hashes() {
1543        let src = r#"{
1544          "version": 2,
1545          "destination_mem": "m",
1546          "operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
1547        }"#;
1548        let b: Binding = serde_json::from_str(src).unwrap();
1549        assert!(b.operations.build.is_none(), "absent build parses to None");
1550        let h = hash_binding(&b);
1551        assert_eq!(h.len(), 64);
1552    }
1553
1554    // ---- capability matrix + validate -----------------------------------
1555
1556    /// The matrix rows are unchanged by the consolidation.
1557    #[test]
1558    fn capability_matrix_rows() {
1559        let web = medium_capabilities(MediumType::Web);
1560        assert!(!web.enumerable && !web.change_signal && !web.base_version_retrievable);
1561        assert!(!web.glob_deny_legal);
1562        assert_eq!(web.anchor_namespace, "url");
1563
1564        let graph = medium_capabilities(MediumType::Graph);
1565        assert!(graph.enumerable && graph.change_signal && graph.base_version_retrievable);
1566        assert!(!graph.glob_deny_legal, "graph namespace is not path-shaped");
1567        assert_eq!(graph.anchor_namespace, "entity");
1568
1569        for ty in [
1570            MediumType::Codebase,
1571            MediumType::Filesystem,
1572            MediumType::Git,
1573        ] {
1574            let c = medium_capabilities(ty);
1575            assert!(c.enumerable && c.change_signal && c.base_version_retrievable);
1576            assert!(c.glob_deny_legal, "{ty:?} allows glob deny_paths");
1577        }
1578        assert_eq!(
1579            medium_capabilities(MediumType::Git).anchor_namespace,
1580            "path+commit"
1581        );
1582    }
1583
1584    /// An empty source name refuses, and a duplicate source name refuses —
1585    /// per-source state keys must be present and collision-free.
1586    #[test]
1587    fn empty_and_duplicate_source_names_refuse() {
1588        let mut b = binding();
1589        b.deny_paths.clear();
1590        b.sources = vec![
1591            source("", MediumType::Codebase, "../a", vec![], None, None),
1592            source("dup", MediumType::Codebase, "../b", vec![], None, None),
1593            source("dup", MediumType::Codebase, "../c", vec![], None, None),
1594        ];
1595        let errs = validate_binding(&b).unwrap_err();
1596        assert!(
1597            errs.iter()
1598                .any(|e| matches!(e, CapabilityError::EmptySourceName)),
1599            "expected EmptySourceName, got {errs:?}"
1600        );
1601        assert!(
1602            errs.iter().any(|e| matches!(
1603                e,
1604                CapabilityError::DuplicateSourceName { name } if name == "dup"
1605            )),
1606            "expected DuplicateSourceName, got {errs:?}"
1607        );
1608    }
1609
1610    /// `sync` and `verify` over a `web` source each refuse as out-of-scope.
1611    #[test]
1612    fn sync_and_verify_over_web_refuse() {
1613        // Web binding, no deny_paths (globs illegal), no prep — isolate the op refusal.
1614        let mut b = binding();
1615        b.deny_paths.clear();
1616        b.sources = vec![source(
1617            "web-source",
1618            MediumType::Web,
1619            "https://example.com",
1620            vec![],
1621            None,
1622            None,
1623        )];
1624        let errs = validate_binding(&b).unwrap_err();
1625        let ops: Vec<&str> = errs
1626            .iter()
1627            .filter_map(|e| match e {
1628                CapabilityError::OperationOutOfScope { operation, .. } => Some(*operation),
1629                _ => None,
1630            })
1631            .collect();
1632        assert!(ops.contains(&"sync"), "sync refused: {errs:?}");
1633        assert!(ops.contains(&"verify"), "verify refused: {errs:?}");
1634    }
1635
1636    /// Glob `deny_paths` over a `graph` source refuses.
1637    #[test]
1638    fn glob_deny_over_graph_refuses() {
1639        let mut b = binding();
1640        b.operations.sync = None;
1641        b.operations.verify = None;
1642        b.deny_paths = vec!["some/**".to_string()];
1643        b.sources = vec![source(
1644            "graph-source",
1645            MediumType::Graph,
1646            "home",
1647            vec![],
1648            None,
1649            None,
1650        )];
1651        let errs = validate_binding(&b).unwrap_err();
1652        assert!(
1653            errs.iter()
1654                .any(|e| matches!(e, CapabilityError::GlobDenyIllegal { .. })),
1655            "expected GlobDenyIllegal, got {errs:?}"
1656        );
1657    }
1658
1659    /// A source preparation the registry does not know refuses at
1660    /// validation time — the narrowed refusal: same error shape, "not in
1661    /// this engine's registry" semantics, the registered set named.
1662    #[test]
1663    fn unregistered_preparation_refuses() {
1664        let mut b = binding();
1665        b.operations.sync = None;
1666        b.operations.verify = None;
1667        b.deny_paths.clear();
1668        b.sources = vec![source(
1669            "manual-pages",
1670            MediumType::Filesystem,
1671            "../docs",
1672            vec![],
1673            Some("pdf-to-markdown"),
1674            None,
1675        )];
1676        let errs = validate_binding(&b).unwrap_err();
1677        let refusal = errs
1678            .iter()
1679            .find(|e| matches!(
1680                e,
1681                CapabilityError::PreparationUnsupported { preparation, impl_version, .. }
1682                    if preparation == "pdf-to-markdown" && *impl_version == PREPARATION_IMPL_VERSION
1683            ))
1684            .unwrap_or_else(|| panic!("expected PreparationUnsupported, got {errs:?}"));
1685        let msg = refusal.to_string();
1686        assert!(
1687            msg.contains("not in this engine's preparation registry"),
1688            "{msg}"
1689        );
1690        assert!(
1691            msg.contains("entity-load-bearing"),
1692            "names the registered set: {msg}"
1693        );
1694        assert!(
1695            !msg.contains("facet"),
1696            "the retired noun stays retired: {msg}"
1697        );
1698    }
1699
1700    /// A registered preparation validates clean over a medium whose anchor
1701    /// namespace admits its grain (`entity-load-bearing` over `graph`), and
1702    /// refuses over one that does not (the same identifier over `codebase`,
1703    /// where no entity-grain anchor could ever meet it).
1704    #[test]
1705    fn registered_preparation_validates_over_its_namespace_only() {
1706        let mut ok = binding();
1707        ok.deny_paths.clear();
1708        ok.sources = vec![source(
1709            "claims",
1710            MediumType::Graph,
1711            "home",
1712            vec![allow("*")],
1713            Some(crate::preparation::ENTITY_LOAD_BEARING),
1714            None,
1715        )];
1716        assert!(
1717            validate_binding(&ok).is_ok(),
1718            "registered preparation over its namespace validates clean: {:?}",
1719            validate_binding(&ok)
1720        );
1721
1722        let mut mismatch = binding();
1723        mismatch.sources = vec![source(
1724            "source-tree",
1725            MediumType::Codebase,
1726            "../public",
1727            vec![allow("**/*.rs")],
1728            Some(crate::preparation::ENTITY_LOAD_BEARING),
1729            None,
1730        )];
1731        let errs = validate_binding(&mismatch).unwrap_err();
1732        assert!(
1733            errs.iter().any(|e| matches!(
1734                e,
1735                CapabilityError::PreparationGrainMismatch { preparation, anchor_namespace, .. }
1736                    if preparation == crate::preparation::ENTITY_LOAD_BEARING && *anchor_namespace == "path"
1737            )),
1738            "expected PreparationGrainMismatch, got {errs:?}"
1739        );
1740        assert!(
1741            !errs
1742                .iter()
1743                .any(|e| matches!(e, CapabilityError::PreparationUnsupported { .. })),
1744            "a registered identifier is never reported as unregistered"
1745        );
1746    }
1747
1748    /// The impl version is hashed for EVERY source, with or without a
1749    /// declared preparation: the hash a prior engine generation computed
1750    /// (impl version 0) differs from the live one, so every finding keyed on
1751    /// it is invalidated by construction when the constant bumps.
1752    #[test]
1753    fn impl_version_is_hashed_into_every_binding() {
1754        let plain = binding();
1755        assert!(plain.sources.iter().all(|s| s.preparation.is_none()));
1756        let live = hash_binding(&plain);
1757        assert_eq!(
1758            live,
1759            hash_binding_at_impl_version(&plain, PREPARATION_IMPL_VERSION)
1760        );
1761        assert_ne!(
1762            live,
1763            hash_binding_at_impl_version(&plain, 0),
1764            "the pre-registry generation's hash differs from the live one"
1765        );
1766        assert_ne!(
1767            live,
1768            hash_binding_at_impl_version(&plain, PREPARATION_IMPL_VERSION + 1)
1769        );
1770
1771        let mut prepared = plain.clone();
1772        prepared.sources[0].preparation = Some(crate::preparation::ENTITY_LOAD_BEARING.to_string());
1773        assert_ne!(
1774            hash_binding(&prepared),
1775            live,
1776            "the identifier is hashed too"
1777        );
1778    }
1779
1780    /// Every combination the matrix marks legal validates clean:
1781    /// codebase / filesystem / git / graph bindings with build+sync+verify all
1782    /// pass (graph carries no glob deny_paths, none carry preparation).
1783    #[test]
1784    fn legal_combinations_validate_clean() {
1785        // codebase / filesystem / git — path-shaped, deny_paths legal.
1786        for ty in [
1787            MediumType::Codebase,
1788            MediumType::Filesystem,
1789            MediumType::Git,
1790        ] {
1791            let mut b = binding();
1792            b.sources = vec![source(
1793                "f",
1794                ty,
1795                "../src",
1796                vec![allow("../src/**")],
1797                None,
1798                None,
1799            )];
1800            assert!(
1801                validate_binding(&b).is_ok(),
1802                "{ty:?} build+sync+verify should validate clean"
1803            );
1804        }
1805        // graph — build+sync+verify legal, but only without glob deny_paths.
1806        let mut graph_binding = binding();
1807        graph_binding.deny_paths.clear();
1808        graph_binding.sources = vec![source("g", MediumType::Graph, "home", vec![], None, None)];
1809        assert!(
1810            validate_binding(&graph_binding).is_ok(),
1811            "graph build+sync+verify with no glob deny should validate clean"
1812        );
1813    }
1814
1815    // ---- F1: prune guarantee -------------------------------------------
1816
1817    /// F1 — the `prune` block is additive: a binding without it deserializes
1818    /// to `prune: None`, and a block that sets a guarantee round-trips
1819    /// (defaulting to `conflict-flag` when the guarantee is absent).
1820    #[test]
1821    fn prune_block_is_additive_and_round_trips() {
1822        let src = r#"{
1823          "version": 2,
1824          "destination_mem": "m",
1825          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1826        }"#;
1827        let b: Binding = serde_json::from_str(src).unwrap();
1828        assert!(b.prune.is_none(), "absent prune parses to None");
1829
1830        // A prune block with no guarantee defaults to conflict-flag.
1831        let with_default = r#"{
1832          "version": 2,
1833          "destination_mem": "m",
1834          "prune": {},
1835          "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1836        }"#;
1837        let b: Binding = serde_json::from_str(with_default).unwrap();
1838        assert_eq!(
1839            b.prune.as_ref().unwrap().guarantee,
1840            PruneGuarantee::ConflictFlag
1841        );
1842
1843        // Explicit never-clobber round-trips.
1844        let explicit = PruneConfig {
1845            guarantee: PruneGuarantee::NeverClobber,
1846        };
1847        let json = serde_json::to_string(&explicit).unwrap();
1848        assert!(json.contains("never-clobber"));
1849        assert_eq!(
1850            serde_json::from_str::<PruneConfig>(&json).unwrap(),
1851            explicit
1852        );
1853    }
1854
1855    /// F1 — the `prune` policy never changes `hash(D)` (it is a maintenance
1856    /// policy, excluded like the sync/verify blocks).
1857    #[test]
1858    fn prune_does_not_change_the_hash() {
1859        let base = hash_binding(&binding());
1860        let mut pruned = binding();
1861        pruned.prune = Some(PruneConfig {
1862            guarantee: PruneGuarantee::NeverClobber,
1863        });
1864        assert_eq!(
1865            base,
1866            hash_binding(&pruned),
1867            "prune policy is excluded from hash(D)"
1868        );
1869    }
1870
1871    /// F1 — the strongest guarantee a medium supports is base-leg-retrievability:
1872    /// git-backed mediums support never-clobber; `web` supports only conflict-flag.
1873    #[test]
1874    fn prune_guarantee_per_medium_matches_capability_matrix() {
1875        for ty in [
1876            MediumType::Codebase,
1877            MediumType::Filesystem,
1878            MediumType::Git,
1879            MediumType::Graph,
1880        ] {
1881            assert_eq!(
1882                prune_guarantee_for_medium(ty),
1883                PruneGuarantee::NeverClobber,
1884                "{ty:?} can retrieve a base leg → never-clobber"
1885            );
1886        }
1887        assert_eq!(
1888            prune_guarantee_for_medium(MediumType::Web),
1889            PruneGuarantee::ConflictFlag,
1890            "web has no retrievable base leg → conflict-flag only"
1891        );
1892    }
1893
1894    /// F1 REFUSAL — requesting `never-clobber` prune over a `web` source (no
1895    /// retrievable base leg) fails at binding validation with a remedy-bearing
1896    /// error naming the downgrade, never a runtime surprise.
1897    #[test]
1898    fn never_clobber_prune_over_web_refuses_with_remedy() {
1899        let mut b = binding();
1900        b.operations.sync = None; // isolate the prune refusal from op-out-of-scope
1901        b.operations.verify = None;
1902        b.deny_paths.clear();
1903        b.prune = Some(PruneConfig {
1904            guarantee: PruneGuarantee::NeverClobber,
1905        });
1906        b.sources = vec![source(
1907            "web-source",
1908            MediumType::Web,
1909            "https://example.com",
1910            vec![],
1911            None,
1912            None,
1913        )];
1914        let errs = validate_binding(&b).unwrap_err();
1915        let refusal = errs
1916            .iter()
1917            .find_map(|e| match e {
1918                CapabilityError::PruneGuaranteeUnsupported {
1919                    requested,
1920                    supported,
1921                    ..
1922                } => Some((*requested, *supported)),
1923                _ => None,
1924            })
1925            .expect("expected a PruneGuaranteeUnsupported refusal");
1926        assert_eq!(refusal, ("never-clobber", "conflict-flag"));
1927        // The message carries the concrete downgrade remedy.
1928        let msg = errs
1929            .iter()
1930            .find(|e| matches!(e, CapabilityError::PruneGuaranteeUnsupported { .. }))
1931            .unwrap()
1932            .to_string();
1933        assert!(
1934            msg.contains("conflict-flag"),
1935            "remedy names the downgrade: {msg}"
1936        );
1937    }
1938
1939    /// F1 — `never-clobber` over a git-backed source validates clean, and
1940    /// `conflict-flag` (the always-supportable degradation) validates clean over
1941    /// `web` — the guarantee the matrix marks legal is accepted.
1942    #[test]
1943    fn prune_guarantee_supported_validates_clean() {
1944        // never-clobber over codebase — base retrievable, clean.
1945        let mut nc = binding();
1946        nc.prune = Some(PruneConfig {
1947            guarantee: PruneGuarantee::NeverClobber,
1948        });
1949        assert!(validate_binding(&nc).is_ok());
1950
1951        // conflict-flag over web — always supportable (build-only to isolate).
1952        let mut cf = binding();
1953        cf.operations.sync = None;
1954        cf.operations.verify = None;
1955        cf.deny_paths.clear();
1956        cf.prune = Some(PruneConfig {
1957            guarantee: PruneGuarantee::ConflictFlag,
1958        });
1959        cf.sources = vec![source(
1960            "web-source",
1961            MediumType::Web,
1962            "https://example.com",
1963            vec![],
1964            None,
1965            None,
1966        )];
1967        assert!(validate_binding(&cf).is_ok());
1968    }
1969
1970    /// A `web` binding scaffolded build-only (no sync/verify, no deny, no prep)
1971    /// validates clean — the matrix-filtered default.
1972    #[test]
1973    fn web_build_only_validates_clean() {
1974        let mut b = binding();
1975        b.operations.sync = None;
1976        b.operations.verify = None;
1977        b.deny_paths.clear();
1978        b.sources = vec![source(
1979            "web-source",
1980            MediumType::Web,
1981            "https://example.com",
1982            vec![],
1983            None,
1984            None,
1985        )];
1986        assert!(validate_binding(&b).is_ok());
1987    }
1988
1989    // ---- coverage semantics: resolution / refusal / hash stability ------
1990
1991    /// A clean web source carries NO scope: the medium has no scope
1992    /// vocabulary, so any rule on it is uninterpretable and refuses. This
1993    /// helper used to hand out `**/*` — which made every web fixture carry a
1994    /// decorative rule, and is why the defect went unnoticed here.
1995    fn web_source(name: &str) -> Source {
1996        source(
1997            name,
1998            MediumType::Web,
1999            "https://example.test",
2000            vec![],
2001            None,
2002            None,
2003        )
2004    }
2005
2006    /// Resolution: an undeclared field resolves per binding — all
2007    /// sources enumerable → exhaustive; at least one non-enumerable
2008    /// source → curated (a mixed binding claims the weaker of its
2009    /// parts). An explicit `curated` validates over any medium and
2010    /// resolves to curated, declared.
2011    #[test]
2012    fn coverage_resolves_per_medium_when_undeclared() {
2013        let enumerable = binding();
2014        assert_eq!(enumerable.coverage_semantics, None);
2015        let eff = effective_coverage_semantics(&enumerable);
2016        assert_eq!(eff.value, CoverageSemantics::Exhaustive);
2017        assert!(!eff.declared, "resolved, not declared");
2018        validate_binding(&enumerable).expect("undeclared over enumerable validates");
2019
2020        // Mixed: one enumerable + one web source → curated.
2021        let mut mixed = binding();
2022        mixed.sources.push(web_source("front"));
2023        // web has no change signal — drop sync/verify so only coverage
2024        // resolution is under test.
2025        mixed.operations.sync = None;
2026        mixed.operations.verify = None;
2027        mixed.deny_paths.clear();
2028        let eff = effective_coverage_semantics(&mixed);
2029        assert_eq!(eff.value, CoverageSemantics::Curated);
2030        assert!(!eff.declared);
2031        validate_binding(&mixed).expect("undeclared over web validates (resolves, never refuses)");
2032
2033        // Explicit curated over any medium: validates, declared.
2034        let mut curated = mixed.clone();
2035        curated.coverage_semantics = Some(CoverageSemantics::Curated);
2036        validate_binding(&curated).expect("explicit curated validates over any medium");
2037        let eff = effective_coverage_semantics(&curated);
2038        assert_eq!(eff.value, CoverageSemantics::Curated);
2039        assert!(eff.declared);
2040    }
2041
2042    /// Refusal: an explicit `exhaustive` with at least one
2043    /// non-enumerable source refuses, naming the source, the medium,
2044    /// and `curated` as the remedy — alongside other refusals of the
2045    /// same binding, not replacing them. Complements: a binding whose
2046    /// ONLY problem is this one still reports it; an explicit
2047    /// `exhaustive` over enumerable sources is NOT refused.
2048    #[test]
2049    fn explicit_exhaustive_over_non_enumerable_refuses() {
2050        // Only-problem case: clean web binding, explicit exhaustive.
2051        let mut only = binding();
2052        only.sources = vec![web_source("front")];
2053        only.operations.sync = None;
2054        only.operations.verify = None;
2055        only.deny_paths.clear();
2056        only.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2057        let errs = validate_binding(&only).expect_err("must refuse");
2058        assert_eq!(errs.len(), 1, "only this refusal: {errs:?}");
2059        match &errs[0] {
2060            CapabilityError::CoverageExhaustiveUnsupported {
2061                source_name,
2062                medium_type,
2063            } => {
2064                assert_eq!(source_name, "front");
2065                assert_eq!(medium_type, "web");
2066            }
2067            other => panic!("expected CoverageExhaustiveUnsupported, got {other:?}"),
2068        }
2069        let msg = errs[0].to_string();
2070        assert!(
2071            msg.contains("'front'") && msg.contains("'web'") && msg.contains("curated"),
2072            "refusal names source, medium, and the curated remedy: {msg}"
2073        );
2074
2075        // Alongside other refusals: keep sync declared (web has no change
2076        // signal) — both refusals must be reported together.
2077        let mut multi = binding();
2078        multi.sources = vec![web_source("front")];
2079        multi.operations.verify = None;
2080        multi.deny_paths.clear();
2081        multi.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2082        assert!(multi.operations.sync.is_some(), "fixture declares sync");
2083        let errs = validate_binding(&multi).expect_err("must refuse");
2084        assert!(
2085            errs.iter()
2086                .any(|e| matches!(e, CapabilityError::CoverageExhaustiveUnsupported { .. })),
2087            "coverage refusal present: {errs:?}"
2088        );
2089        assert!(
2090            errs.iter()
2091                .any(|e| matches!(e, CapabilityError::OperationOutOfScope { .. })),
2092            "reported alongside the sync refusal, not replacing it: {errs:?}"
2093        );
2094
2095        // Complement: explicit exhaustive over enumerable is NOT refused.
2096        let mut ok = binding();
2097        ok.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2098        validate_binding(&ok).expect("explicit exhaustive over enumerable validates");
2099    }
2100
2101    /// Hash stability: the hash serialises the RESOLVED value, never
2102    /// the `Option`. Over enumerable sources, an undeclared field
2103    /// hashes byte-identically to an explicit `exhaustive` (== the
2104    /// pre-optionality bytes, whose serialized projection was the
2105    /// same `"exhaustive"` value). Over a non-enumerable source, an
2106    /// undeclared field hashes identically to an explicit `curated`
2107    /// (the moved-once, stable-thereafter hash) and differently from
2108    /// the enumerable case's resolution.
2109    #[test]
2110    fn hash_serialises_the_resolved_coverage_value() {
2111        // Enumerable: None == Some(Exhaustive), byte-for-byte.
2112        let undeclared = binding();
2113        let mut declared = binding();
2114        declared.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2115        assert_eq!(
2116            hash_binding(&undeclared),
2117            hash_binding(&declared),
2118            "undeclared over enumerable keeps the pre-optionality hash"
2119        );
2120        // ...and an explicit curated moves it (a genuine coverage change).
2121        let mut curated = binding();
2122        curated.coverage_semantics = Some(CoverageSemantics::Curated);
2123        assert_ne!(hash_binding(&undeclared), hash_binding(&curated));
2124
2125        // Non-enumerable: None == Some(Curated) — the one-time move is
2126        // to the curated hash, stable thereafter.
2127        let mut web_undeclared = binding();
2128        web_undeclared.sources = vec![web_source("front")];
2129        let mut web_curated = web_undeclared.clone();
2130        web_curated.coverage_semantics = Some(CoverageSemantics::Curated);
2131        assert_eq!(
2132            hash_binding(&web_undeclared),
2133            hash_binding(&web_curated),
2134            "undeclared over web resolves (and hashes) as curated"
2135        );
2136    }
2137}