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