Skip to main content

memstead_base/
binding.rs

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