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