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