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