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 /// A scope pattern is not a compilable glob. Refused at validation so a
611 /// malformed pattern never reaches the enumerator, where an all-or-nothing
612 /// glob set turned one bad allow into an empty denominator and one bad
613 /// deny into no denies at all — both silently.
614 #[error(
615 "source '{source_name}' declares a scope pattern that is not a valid glob: \
616 '{pattern}' ({reason})"
617 )]
618 MalformedScopePattern {
619 /// The source declaring it.
620 source_name: String,
621 /// The pattern as written.
622 pattern: String,
623 /// The glob compiler's own reason.
624 reason: String,
625 },
626 /// Two sources in the record share a name — per-source state keys would
627 /// collide.
628 #[error(
629 "duplicate source name '{name}': source names are unique within a binding \
630 (they key per-source sync/verify state)"
631 )]
632 DuplicateSourceName {
633 /// The colliding name.
634 name: String,
635 },
636 /// A `sync` / `verify` operation is declared over a medium that cannot
637 /// support it this cycle (a `web` source — operator decision 7). The
638 /// out-of-scope statement is said out loud, never a silent mtime-over-URL.
639 #[error(
640 "operation '{operation}' is out of scope for source '{source_name}' over a '{medium_type}' \
641 medium: this medium has no change signal this cycle (deferred — operator decision 7)"
642 )]
643 OperationOutOfScope {
644 /// The offending operation.
645 operation: &'static str,
646 /// The source declaring it.
647 source_name: String,
648 /// The medium type that cannot support the operation.
649 medium_type: String,
650 },
651 /// A `graph` source's scope carries a pattern the entity-namespace
652 /// vocabulary does not define. Refused at declaration rather than
653 /// silently selecting nothing: a scope that looks like selection but
654 /// reaches nothing is the defect this rule exists to prevent.
655 #[error(
656 "scope pattern '{pattern}' on source '{source_name}' is not a legal entity selector: a \
657 graph medium selects entities, not paths — write '*' for the whole mem, \
658 'type:<entity_type>', or 'id:<glob>'"
659 )]
660 GraphScopeNotEntitySelector {
661 /// The source declaring it.
662 source_name: String,
663 /// The offending pattern, verbatim.
664 pattern: String,
665 },
666 /// A source's scope carries a pattern its medium has no vocabulary to
667 /// express at all, so nothing anywhere can interpret it. Distinct from
668 /// [`Self::GraphScopeNotEntitySelector`], which names the legal forms
669 /// because a legal form exists; here there is none, so the only honest
670 /// scope is no scope.
671 #[error(
672 "scope pattern '{pattern}' on source '{source_name}' cannot be interpreted: a \
673 '{medium_type}' medium has no scope vocabulary, so the pattern would select \
674 nothing while looking like selection — remove the scope rule"
675 )]
676 ScopeNotInterpretable {
677 /// The source declaring it.
678 source_name: String,
679 /// The offending pattern, verbatim.
680 pattern: String,
681 /// The medium with no scope vocabulary.
682 medium_type: String,
683 },
684 /// Glob `deny_paths` are declared over a medium whose namespace is not
685 /// path-shaped (`graph`, `web`) — a glob cannot select in that namespace.
686 #[error(
687 "glob deny_paths are illegal for source '{source_name}' over a '{medium_type}' medium: its \
688 '{anchor_namespace}' namespace is not path-shaped"
689 )]
690 GlobDenyIllegal {
691 /// The offending source.
692 source_name: String,
693 /// The medium type whose namespace is not path-shaped.
694 medium_type: String,
695 /// That medium's anchor namespace.
696 anchor_namespace: &'static str,
697 },
698 /// A source declares a preparation identifier the engine's preparation
699 /// registry ([`crate::preparation`]) does not know. The refusal is
700 /// exactly "not in this engine's registry": a registered identifier
701 /// validates clean, an unknown one refuses, and the message names the
702 /// registered set.
703 ///
704 /// Raised by [`validate_binding`], which the edit/validate paths call —
705 /// NOT `projection init` (which has no `--preparation` flag). The brief
706 /// renderer mirrors the same rule for a record that acquired an unknown
707 /// identifier by hand (accepted at rest, reported unsupported and
708 /// skipped at run time with exit 0; see `GLOSSARY.md` and
709 /// `crate::pipeline::Source::preparation`), so both refusal paths carry
710 /// one semantics and move together.
711 #[error(
712 "source '{source_name}' declares preparation '{preparation}', which is not in this \
713 engine's preparation registry (registered: {}; preparation impl version {impl_version})",
714 crate::preparation::registered_identifiers().join(", ")
715 )]
716 PreparationUnsupported {
717 /// The offending source.
718 source_name: String,
719 /// The declared preparation identifier.
720 preparation: String,
721 /// The current preparation-implementation version.
722 impl_version: u32,
723 },
724 /// A registered preparation is declared over a medium whose anchor
725 /// namespace admits none of the grains it prepares (`entity-load-bearing`
726 /// over a `codebase` source). It would never meet an anchor it applies
727 /// to, so the declaration is refused at validation rather than accepted
728 /// and silently never applying.
729 #[error(
730 "source '{source_name}' declares preparation '{preparation}' over a '{medium_type}' \
731 medium whose '{anchor_namespace}' anchor namespace admits none of the grains it \
732 prepares"
733 )]
734 PreparationGrainMismatch {
735 /// The offending source.
736 source_name: String,
737 /// The declared (registered) preparation identifier.
738 preparation: String,
739 /// The medium type it was declared over.
740 medium_type: String,
741 /// That medium's anchor namespace.
742 anchor_namespace: &'static str,
743 },
744 /// The binding declares `coverage_semantics: exhaustive` while at least
745 /// one source sits on a medium whose scope the engine cannot enumerate
746 /// (`web`) — `S(D)` is not computable, so exhaustive coverage cannot be
747 /// asserted over it. Refused at binding-validation time with `curated`
748 /// as the remedy. An *undeclared* field never trips this: it resolves
749 /// per medium via [`effective_coverage_semantics`].
750 #[error(
751 "coverage_semantics 'exhaustive' is unsupported for source '{source_name}' over a \
752 '{medium_type}' medium: its scope is not enumerable (S(D) is not computable), so \
753 exhaustive coverage cannot be asserted — declare 'curated', or omit the field to \
754 resolve per medium"
755 )]
756 CoverageExhaustiveUnsupported {
757 /// The offending source.
758 source_name: String,
759 /// The medium type whose scope is not enumerable.
760 medium_type: String,
761 },
762 /// The binding requests a `prune` guarantee the source's medium cannot
763 /// support (F1) — `never-clobber` over a medium whose base leg is not
764 /// retrievable (`web`). Refused at binding-validation time with the
765 /// downgrade remedy, never discovered at run time.
766 #[error(
767 "prune guarantee '{requested}' is unsupported for source '{source_name}' over a \
768 '{medium_type}' medium: its base leg is not retrievable, so only '{supported}' \
769 degradation is possible — set the binding's prune guarantee to '{supported}', or \
770 point the source at a git-backed medium"
771 )]
772 PruneGuaranteeUnsupported {
773 /// The offending source.
774 source_name: String,
775 /// The medium type that cannot support the requested guarantee.
776 medium_type: String,
777 /// The requested guarantee wire string.
778 requested: &'static str,
779 /// The strongest guarantee this medium supports (the downgrade remedy).
780 supported: &'static str,
781 },
782}
783
784/// Validate a binding against the medium-capability matrix and the in-record
785/// source rules, returning **every** refusal (empty `Err` never returned —
786/// `Ok` means clean). The v2 record needs no external resolution: everything
787/// validated lives inside the one record.
788///
789/// Refuses:
790/// - an empty or duplicate source `name`
791/// ([`CapabilityError::EmptySourceName`] /
792/// [`CapabilityError::DuplicateSourceName`]) — names key per-source state;
793/// - a declared `sync` / `verify` operation over a `web` source
794/// ([`CapabilityError::OperationOutOfScope`]);
795/// - a glob `deny_paths` list over a non-path-namespace medium
796/// ([`CapabilityError::GlobDenyIllegal`]);
797/// - a source preparation the engine's registry does not know
798/// ([`CapabilityError::PreparationUnsupported`]), or a registered one
799/// over a medium whose anchor namespace admits none of its grains
800/// ([`CapabilityError::PreparationGrainMismatch`]);
801/// - a declared `coverage_semantics: exhaustive` over a non-enumerable
802/// medium ([`CapabilityError::CoverageExhaustiveUnsupported`]);
803/// - a `prune` block requesting `never-clobber` over a non-base-retrievable
804/// medium ([`CapabilityError::PruneGuaranteeUnsupported`], F1).
805pub fn validate_binding(binding: &Binding) -> Result<(), Vec<CapabilityError>> {
806 let mut refusals = Vec::new();
807 let has_deny = !binding.deny_paths.is_empty();
808 let sync_declared = binding.operations.sync.is_some();
809 let verify_declared = binding.operations.verify.is_some();
810 // F1: a `prune` block requesting `never-clobber` needs a base-retrievable
811 // medium on every source; refuse per-source where it cannot be honoured.
812 let requested_prune = binding
813 .prune
814 .as_ref()
815 .map(|p| p.guarantee)
816 .filter(|g| *g == PruneGuarantee::NeverClobber);
817
818 let mut seen_names: Vec<&str> = Vec::new();
819 for source in &binding.sources {
820 if source.name.is_empty() {
821 refusals.push(CapabilityError::EmptySourceName);
822 } else if seen_names.contains(&source.name.as_str()) {
823 refusals.push(CapabilityError::DuplicateSourceName {
824 name: source.name.clone(),
825 });
826 } else {
827 seen_names.push(&source.name);
828 }
829
830 // A scope pattern must compile. Path-shaped mediums only: a graph
831 // source's scope entries are entity selectors, a different grammar
832 // with its own parser.
833 if !matches!(source.medium_type, MediumType::Graph | MediumType::Web) {
834 for rule in &source.scope {
835 if let Err(e) = globset::Glob::new(&rule.path) {
836 refusals.push(CapabilityError::MalformedScopePattern {
837 source_name: source.name.clone(),
838 pattern: rule.path.clone(),
839 reason: e.to_string(),
840 });
841 }
842 }
843 }
844
845 let caps = medium_capabilities(source.medium_type);
846 let medium_type = serde_json::to_value(source.medium_type)
847 .ok()
848 .and_then(|v| v.as_str().map(str::to_string))
849 .unwrap_or_default();
850
851 // A declared preparation must be one the registry knows — the
852 // touchpoints consult the registry by identifier, so an unknown one
853 // could never be applied — and one that can apply over this medium's
854 // anchor namespace.
855 if let Some(prep) = &source.preparation {
856 match crate::preparation::lookup(prep) {
857 None => refusals.push(CapabilityError::PreparationUnsupported {
858 source_name: source.name.clone(),
859 preparation: prep.clone(),
860 impl_version: PREPARATION_IMPL_VERSION,
861 }),
862 Some(registered)
863 if !crate::preparation::applies_to_namespace(
864 registered,
865 caps.anchor_namespace,
866 ) =>
867 {
868 refusals.push(CapabilityError::PreparationGrainMismatch {
869 source_name: source.name.clone(),
870 preparation: prep.clone(),
871 medium_type: medium_type.clone(),
872 anchor_namespace: caps.anchor_namespace,
873 });
874 }
875 Some(_) => {}
876 }
877 }
878
879 // sync / verify over a medium with no change signal (web) is out of scope.
880 if !caps.change_signal {
881 for (declared, op) in [
882 (sync_declared, Operation::Sync),
883 (verify_declared, Operation::Verify),
884 ] {
885 if declared {
886 refusals.push(CapabilityError::OperationOutOfScope {
887 operation: op.name(),
888 source_name: source.name.clone(),
889 medium_type: medium_type.clone(),
890 });
891 }
892 }
893 }
894
895 // A scope rule must be one its medium's namespace can express. The
896 // engine used to accept any string here and interpret none of them,
897 // so `**/*` scaffolded onto a graph facet looked like scope and
898 // selected nothing. Refuse the undefined form at declaration.
899 //
900 // Checked for every medium whose namespace is not path-shaped, not for
901 // graph alone: `web` has no selector vocabulary either, and gating on
902 // one medium is how the class survived a round — fixed where it had
903 // been demonstrated and left standing one row over.
904 match source.medium_type {
905 MediumType::Graph => {
906 for rule in &source.scope {
907 if crate::ingest::cursor::parse_entity_selector(&rule.path).is_none() {
908 refusals.push(CapabilityError::GraphScopeNotEntitySelector {
909 source_name: source.name.clone(),
910 pattern: rule.path.clone(),
911 });
912 }
913 }
914 }
915 MediumType::Web => {
916 for rule in &source.scope {
917 refusals.push(CapabilityError::ScopeNotInterpretable {
918 source_name: source.name.clone(),
919 pattern: rule.path.clone(),
920 medium_type: medium_type.clone(),
921 });
922 }
923 }
924 MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {}
925 }
926
927 // Glob deny_paths over a non-path-shaped namespace is illegal.
928 if has_deny && !caps.glob_deny_legal {
929 refusals.push(CapabilityError::GlobDenyIllegal {
930 source_name: source.name.clone(),
931 medium_type: medium_type.clone(),
932 anchor_namespace: caps.anchor_namespace,
933 });
934 }
935
936 // A declared `exhaustive` over a non-enumerable medium is refused —
937 // the engine cannot compute S(D) there, so the claim is unassertable.
938 // Fires only on what the author actually wrote (`Some(Exhaustive)`);
939 // an undeclared field resolves per medium instead of refusing.
940 if binding.coverage_semantics == Some(CoverageSemantics::Exhaustive) && !caps.enumerable {
941 refusals.push(CapabilityError::CoverageExhaustiveUnsupported {
942 source_name: source.name.clone(),
943 medium_type: medium_type.clone(),
944 });
945 }
946
947 // F1: requested `never-clobber` prune over a non-base-retrievable medium
948 // is refused with the downgrade remedy — at validation, not run time.
949 if requested_prune.is_some() && !caps.base_version_retrievable {
950 refusals.push(CapabilityError::PruneGuaranteeUnsupported {
951 source_name: source.name.clone(),
952 medium_type: medium_type.clone(),
953 requested: PruneGuarantee::NeverClobber.as_wire(),
954 supported: prune_guarantee_for_medium(source.medium_type).as_wire(),
955 });
956 }
957 }
958
959 if refusals.is_empty() {
960 Ok(())
961 } else {
962 Err(refusals)
963 }
964}
965
966/// What a caller wants scaffolded: one binding over one source. Everything
967/// else — deny defaults, the capability-matrix filter, the prune block — is
968/// the engine's to decide, so that every front door that scaffolds a binding
969/// scaffolds the same one.
970#[derive(Debug, Clone)]
971pub struct ScaffoldParams<'a> {
972 /// The mem the binding writes into — the `<mem>` half of the binding id.
973 pub destination_mem: &'a str,
974 /// The single source's `name` (unique within the record; keys per-source
975 /// state). Conventionally the binding stem.
976 pub source_name: &'a str,
977 /// The medium pointer — workspace-relative path, mem id, or URL.
978 pub pointer: &'a str,
979 /// The medium type, which decides the capability matrix.
980 pub medium_type: MediumType,
981 /// Intent prose for the agent, or `None`.
982 pub intent: Option<String>,
983 /// Deny globs to add beyond [`DEFAULT_SCAFFOLD_DENY_PATHS`], for a caller
984 /// that knows something about the tree the engine cannot infer. Materialised
985 /// into the record exactly like the defaults — visible, editable, deletable.
986 /// Engine state and mount storage locations never belong here: their
987 /// exclusion is unconditional in the strategy layer.
988 pub additional_deny_paths: Vec<String>,
989}
990
991/// A scaffolded binding, ready to write: the record, the operations it ended
992/// up declaring, and the warnings the caller must surface.
993#[derive(Debug, Clone)]
994pub struct ScaffoldedBinding {
995 /// The record to write.
996 pub binding: Binding,
997 /// The operation names the record declares, in `build, sync, verify` order
998 /// — the matrix may have stripped some.
999 pub operations: Vec<&'static str>,
1000 /// Capability refusals the scaffold resolved by stripping an operation,
1001 /// rendered for the caller's output. Never a failure: a scaffold that
1002 /// declares less than asked says so rather than refusing.
1003 pub warnings: Vec<String>,
1004}
1005
1006/// Scaffold the default binding record for one source — the single
1007/// definition of "a fresh binding", shared by every front door that creates
1008/// one (`memstead projection init`, the guided `memstead quickstart` path,
1009/// any embedder).
1010///
1011/// The record: one inline [`Source`] scoped `**/*` (a scoped default — an
1012/// unscoped source refuses at run time), the enumerable-medium deny defaults
1013/// materialised into the record (see [`DEFAULT_SCAFFOLD_DENY_PATHS`] for why
1014/// they are recorded rather than injected), unstated `coverage_semantics`
1015/// (the scaffold asserts nothing), and `build` + `sync` + `verify` filtered
1016/// through the capability matrix — a `web` source loses sync/verify and the
1017/// deferral rides `warnings`. Prune is scaffolded wherever sync survived,
1018/// with the strongest guarantee the medium supports.
1019pub fn scaffold_binding(params: ScaffoldParams<'_>) -> ScaffoldedBinding {
1020 let ScaffoldParams {
1021 destination_mem,
1022 source_name,
1023 pointer,
1024 medium_type,
1025 intent,
1026 additional_deny_paths,
1027 } = params;
1028
1029 let source = Source {
1030 name: source_name.to_string(),
1031 medium_type,
1032 pointer: pointer.to_string(),
1033 change_detection: None,
1034 // Scope is medium-shaped. A path glob over a graph source is not a
1035 // narrower scope — it is an uninterpreted string: nothing anywhere
1036 // matches globs against entity ids, so `**/*` scaffolded a facet that
1037 // looked scoped and selected nothing. The graph namespace gets its own
1038 // whole-mem selector; every path medium keeps `**/*` byte-for-byte.
1039 //
1040 // `web` gets no scope rule at all. Its namespace is `url`, nothing
1041 // enumerates it, and no selector vocabulary exists for it — so any
1042 // pattern scaffolded here would be decorative in exactly the way the
1043 // graph glob was, and the brief would print it at an agent as
1044 // selection. An absent scope is the honest scaffold: it renders as
1045 // unmonitored rather than as a scope that reaches nothing.
1046 scope: match medium_type {
1047 MediumType::Graph => vec![PatternEntry {
1048 path: "*".to_string(),
1049 mode: crate::pipeline::PatternMode::Allow,
1050 }],
1051 MediumType::Web => Vec::new(),
1052 _ => vec![PatternEntry {
1053 path: "**/*".to_string(),
1054 mode: crate::pipeline::PatternMode::Allow,
1055 }],
1056 },
1057 engagement: None,
1058 preparation: None,
1059 };
1060
1061 let mut deny_paths: Vec<String> =
1062 if matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
1063 DEFAULT_SCAFFOLD_DENY_PATHS
1064 .iter()
1065 .map(|s| s.to_string())
1066 .collect()
1067 } else {
1068 Vec::new()
1069 };
1070 for extra in additional_deny_paths {
1071 if !deny_paths.contains(&extra) {
1072 deny_paths.push(extra);
1073 }
1074 }
1075
1076 let mut binding = Binding {
1077 version: BINDING_VERSION,
1078 intent,
1079 sources: vec![source],
1080 reference_mems: Vec::new(),
1081 destination_mem: destination_mem.to_string(),
1082 deny_paths,
1083 coverage_semantics: None,
1084 rules: None,
1085 prune: None,
1086 operations: Operations {
1087 build: Some(BuildOperation {
1088 mode: BuildMode::Discovery,
1089 trigger: IngestTrigger::Loop,
1090 batch_size: 20,
1091 post_actions: None,
1092 }),
1093 sync: Some(SyncOperation {
1094 trigger: IngestTrigger::Manual,
1095 batch_size: 20,
1096 }),
1097 verify: Some(VerifyOperation {
1098 trigger: IngestTrigger::Manual,
1099 batch_size: 20,
1100 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1101 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1102 }),
1103 },
1104 };
1105
1106 let mut warnings: Vec<String> = Vec::new();
1107 if let Err(refusals) = validate_binding(&binding) {
1108 for r in &refusals {
1109 if let CapabilityError::OperationOutOfScope { operation, .. } = r {
1110 match *operation {
1111 "sync" => binding.operations.sync = None,
1112 "verify" => binding.operations.verify = None,
1113 _ => {}
1114 }
1115 }
1116 warnings.push(r.to_string());
1117 }
1118 }
1119
1120 if binding.operations.sync.is_some() {
1121 binding.prune = Some(PruneConfig {
1122 guarantee: prune_guarantee_for_medium(medium_type),
1123 });
1124 }
1125
1126 let mut operations: Vec<&'static str> = vec!["build"];
1127 if binding.operations.sync.is_some() {
1128 operations.push("sync");
1129 }
1130 if binding.operations.verify.is_some() {
1131 operations.push("verify");
1132 }
1133
1134 ScaffoldedBinding {
1135 binding,
1136 operations,
1137 warnings,
1138 }
1139}
1140
1141#[cfg(test)]
1142mod scaffold_tests {
1143 use super::*;
1144
1145 /// The scaffold is one definition, so every front door writes the same
1146 /// record: scoped source, materialised deny defaults, full operations.
1147 #[test]
1148 fn codebase_scaffold_carries_the_deny_defaults_and_every_operation() {
1149 let s = scaffold_binding(ScaffoldParams {
1150 destination_mem: "app",
1151 source_name: "app",
1152 pointer: ".",
1153 medium_type: MediumType::Codebase,
1154 intent: Some("model it".to_string()),
1155 additional_deny_paths: Vec::new(),
1156 });
1157 assert_eq!(s.operations, vec!["build", "sync", "verify"]);
1158 assert_eq!(s.warnings, Vec::<String>::new());
1159 assert_eq!(s.binding.deny_paths, DEFAULT_SCAFFOLD_DENY_PATHS);
1160 assert_eq!(s.binding.sources[0].scope[0].path, "**/*");
1161 assert_eq!(s.binding.sources[0].pointer, ".");
1162 assert!(s.binding.coverage_semantics.is_none(), "asserts nothing");
1163 assert!(
1164 s.binding.prune.is_some(),
1165 "sync survived, so prune rides it"
1166 );
1167 }
1168
1169 /// A caller's extra deny entries are materialised alongside the
1170 /// defaults — visible, editable, and never silently deduplicated away
1171 /// into a different list.
1172 #[test]
1173 fn additional_deny_paths_are_appended_once() {
1174 let s = scaffold_binding(ScaffoldParams {
1175 destination_mem: "app",
1176 source_name: "app",
1177 pointer: ".",
1178 medium_type: MediumType::Codebase,
1179 intent: None,
1180 additional_deny_paths: vec!["build/**".to_string(), "**/.git/**".to_string()],
1181 });
1182 let expected: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
1183 .iter()
1184 .map(|s| s.to_string())
1185 .chain(std::iter::once("build/**".to_string()))
1186 .collect();
1187 assert_eq!(s.binding.deny_paths, expected);
1188 }
1189
1190 /// A medium the matrix cannot serve loses the operation and says so,
1191 /// rather than scaffolding a record that refuses at run time.
1192 #[test]
1193 fn web_scaffold_loses_sync_and_verify_with_a_warning() {
1194 let s = scaffold_binding(ScaffoldParams {
1195 destination_mem: "app",
1196 source_name: "manual",
1197 pointer: "https://example.com/manual",
1198 medium_type: MediumType::Web,
1199 intent: None,
1200 additional_deny_paths: Vec::new(),
1201 });
1202 assert_eq!(s.operations, vec!["build"]);
1203 assert!(!s.warnings.is_empty(), "the deferral is named");
1204 assert!(s.binding.deny_paths.is_empty(), "no path denies over web");
1205 assert!(s.binding.prune.is_none(), "no sync, no prune");
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211
1212 /// Scaffolded scope is medium-shaped, and every rule it writes is one
1213 /// something interprets. The path mediums keep `**/*` byte-for-byte;
1214 /// `graph` gets the entity vocabulary; `web` gets NO rule, because its
1215 /// namespace has no selector vocabulary and a pattern there would be
1216 /// decorative in exactly the way the graph glob was — printed at an agent
1217 /// as selection while reaching nothing.
1218 #[test]
1219 fn scaffolded_scope_is_medium_shaped_and_never_decorative() {
1220 let scope_of = |medium: MediumType| {
1221 scaffold_binding(ScaffoldParams {
1222 destination_mem: "m",
1223 source_name: "s",
1224 pointer: "p",
1225 medium_type: medium,
1226 intent: None,
1227 additional_deny_paths: Vec::new(),
1228 })
1229 .binding
1230 .sources[0]
1231 .scope
1232 .iter()
1233 .map(|r| r.path.clone())
1234 .collect::<Vec<_>>()
1235 };
1236
1237 // Unchanged, and asserted so a graph-shaped fix can never drift them.
1238 assert_eq!(scope_of(MediumType::Codebase), vec!["**/*".to_string()]);
1239 assert_eq!(scope_of(MediumType::Filesystem), vec!["**/*".to_string()]);
1240 assert_eq!(scope_of(MediumType::Git), vec!["**/*".to_string()]);
1241
1242 // Entity namespace: a legal selector, and one the run time honours.
1243 assert_eq!(scope_of(MediumType::Graph), vec!["*".to_string()]);
1244 assert!(
1245 crate::ingest::cursor::parse_entity_selector("*").is_some(),
1246 "the graph scaffold writes a selector the parser accepts"
1247 );
1248
1249 // No vocabulary exists, so no rule is written.
1250 assert!(
1251 scope_of(MediumType::Web).is_empty(),
1252 "a web facet carries no scope rather than one nothing interprets"
1253 );
1254 }
1255 use super::*;
1256 use crate::pipeline::PatternMode;
1257
1258 // ---- builders -------------------------------------------------------
1259
1260 fn build_op() -> BuildOperation {
1261 BuildOperation {
1262 mode: BuildMode::Discovery,
1263 trigger: IngestTrigger::Loop,
1264 batch_size: 20,
1265 post_actions: None,
1266 }
1267 }
1268
1269 fn allow(path: &str) -> PatternEntry {
1270 PatternEntry {
1271 path: path.to_string(),
1272 mode: PatternMode::Allow,
1273 }
1274 }
1275
1276 fn source(
1277 name: &str,
1278 medium_type: MediumType,
1279 pointer: &str,
1280 scope: Vec<PatternEntry>,
1281 preparation: Option<&str>,
1282 change_detection: Option<&str>,
1283 ) -> Source {
1284 Source {
1285 name: name.to_string(),
1286 medium_type,
1287 pointer: pointer.to_string(),
1288 change_detection: change_detection.map(str::to_string),
1289 scope,
1290 engagement: None,
1291 preparation: preparation.map(str::to_string),
1292 }
1293 }
1294
1295 fn codebase_source() -> Source {
1296 source(
1297 "source-tree",
1298 MediumType::Codebase,
1299 "../public",
1300 vec![allow("../public/**/*.rs")],
1301 None,
1302 None,
1303 )
1304 }
1305
1306 fn binding() -> Binding {
1307 Binding {
1308 version: BINDING_VERSION,
1309 intent: Some("prose for the agent".to_string()),
1310 sources: vec![codebase_source()],
1311 reference_mems: vec!["engine".to_string()],
1312 destination_mem: "plugin".to_string(),
1313 deny_paths: vec!["VISION.md".to_string(), "dev/**".to_string()],
1314 coverage_semantics: None,
1315 rules: Some(serde_json::json!({ "routing": "…" })),
1316 prune: None,
1317 operations: Operations {
1318 build: Some(build_op()),
1319 sync: Some(SyncOperation {
1320 trigger: IngestTrigger::Manual,
1321 batch_size: 20,
1322 }),
1323 verify: Some(VerifyOperation {
1324 trigger: IngestTrigger::Manual,
1325 batch_size: 20,
1326 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1327 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1328 }),
1329 },
1330 }
1331 }
1332
1333 // ---- Binding serde --------------------------------------------------
1334
1335 /// A v2 binding round-trips: serialize → deserialize → equal.
1336 #[test]
1337 fn binding_round_trips() {
1338 let b = binding();
1339 let json = serde_json::to_string(&b).unwrap();
1340 let back: Binding = serde_json::from_str(&json).unwrap();
1341 assert_eq!(back, b);
1342 }
1343
1344 /// The plan's v2 wire example deserializes: inline sources with both
1345 /// halves, the operations block, and coverage semantics as declared.
1346 #[test]
1347 fn plan_shaped_v2_json_deserializes() {
1348 let src = r#"{
1349 "version": 2,
1350 "intent": "prose the building agent reads before every run",
1351 "sources": [
1352 {
1353 "name": "source-tree",
1354 "type": "codebase",
1355 "pointer": "../public",
1356 "change_detection": "auto",
1357 "scope": [
1358 { "path": "../public/**/*.rs", "mode": "allow" },
1359 { "path": "../public/target/**", "mode": "deny" }
1360 ]
1361 }
1362 ],
1363 "reference_mems": ["engineering"],
1364 "destination_mem": "engine",
1365 "deny_paths": ["../dev/**"],
1366 "coverage_semantics": "exhaustive",
1367 "operations": {
1368 "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1369 "sync": { "trigger": "loop", "batch_size": 20 },
1370 "verify": { "trigger": "loop", "batch_size": 20,
1371 "adjudication_cap": 50, "full_resync_every": 20 }
1372 }
1373 }"#;
1374 let b: Binding = serde_json::from_str(src).unwrap();
1375 assert_eq!(b.version, 2);
1376 assert_eq!(b.destination_mem, "engine");
1377 assert_eq!(b.sources.len(), 1);
1378 let s = &b.sources[0];
1379 assert_eq!(s.name, "source-tree");
1380 assert_eq!(s.medium_type, MediumType::Codebase);
1381 assert_eq!(s.pointer, "../public");
1382 assert_eq!(s.change_detection.as_deref(), Some("auto"));
1383 assert_eq!(s.scope.len(), 2);
1384 assert_eq!(b.reference_mems, vec!["engineering".to_string()]);
1385 assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Exhaustive));
1386 assert_eq!(
1387 b.operations.build.as_ref().unwrap().mode,
1388 BuildMode::Discovery
1389 );
1390 assert!(b.operations.sync.is_some());
1391 assert_eq!(b.operations.verify.as_ref().unwrap().adjudication_cap, 50);
1392 }
1393
1394 /// An absent `coverage_semantics` deserializes to `None` ("not
1395 /// stated" — resolved per medium, never a baked-in default), and
1396 /// `one-shot` is the kebab wire form.
1397 #[test]
1398 fn coverage_defaults_and_one_shot_wire_form() {
1399 let src = r#"{
1400 "version": 2,
1401 "destination_mem": "m",
1402 "operations": { "build": { "mode": "one-shot", "trigger": "manual", "batch_size": 5 } }
1403 }"#;
1404 let b: Binding = serde_json::from_str(src).unwrap();
1405 assert_eq!(b.coverage_semantics, None, "absent = not stated");
1406 assert_eq!(
1407 b.operations.build.as_ref().unwrap().mode,
1408 BuildMode::OneShot
1409 );
1410 assert!(b.operations.sync.is_none());
1411 assert!(b.operations.verify.is_none());
1412 // one-shot serializes to the kebab form.
1413 assert_eq!(
1414 serde_json::to_string(&BuildMode::OneShot).unwrap(),
1415 r#""one-shot""#
1416 );
1417 }
1418
1419 /// The tier-3 knobs are additive: a `verify` block without them
1420 /// deserializes to the dogfood-tuned defaults, and a block that sets them
1421 /// round-trips its values.
1422 #[test]
1423 fn verify_tier3_knobs_default_and_round_trip() {
1424 let src = r#"{
1425 "version": 2,
1426 "destination_mem": "m",
1427 "operations": {
1428 "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
1429 "verify": { "trigger": "manual", "batch_size": 20 }
1430 }
1431 }"#;
1432 let b: Binding = serde_json::from_str(src).unwrap();
1433 let v = b.operations.verify.as_ref().unwrap();
1434 assert_eq!(v.adjudication_cap, DEFAULT_ADJUDICATION_CAP);
1435 assert_eq!(v.full_resync_every, DEFAULT_FULL_RESYNC_EVERY);
1436
1437 // Explicit values round-trip.
1438 let explicit = VerifyOperation {
1439 trigger: IngestTrigger::Manual,
1440 batch_size: 10,
1441 adjudication_cap: 7,
1442 full_resync_every: 3,
1443 };
1444 let json = serde_json::to_string(&explicit).unwrap();
1445 let back: VerifyOperation = serde_json::from_str(&json).unwrap();
1446 assert_eq!(back, explicit);
1447 assert!(json.contains("adjudication_cap"));
1448 assert!(json.contains("full_resync_every"));
1449 }
1450
1451 /// The tier-3 scheduling knobs never change `hash(D)` — they are excluded
1452 /// with the rest of the `verify` block (scheduling never changes the claim).
1453 #[test]
1454 fn tier3_knobs_do_not_change_the_hash() {
1455 let base = hash_binding(&binding());
1456 let mut tuned = binding();
1457 let v = tuned.operations.verify.as_mut().unwrap();
1458 v.adjudication_cap = 999;
1459 v.full_resync_every = 1;
1460 assert_eq!(
1461 base,
1462 hash_binding(&tuned),
1463 "tier-3 verify knobs are excluded from hash(D)"
1464 );
1465 }
1466
1467 /// `"mode": "refinement"` is a deleted value — deserialization fails.
1468 #[test]
1469 fn refinement_mode_is_rejected() {
1470 let src = r#"{
1471 "version": 2,
1472 "destination_mem": "m",
1473 "operations": { "build": { "mode": "refinement", "trigger": "loop", "batch_size": 20 } }
1474 }"#;
1475 let err = serde_json::from_str::<Binding>(src).unwrap_err();
1476 assert!(
1477 err.to_string().contains("refinement") || err.to_string().contains("unknown variant"),
1478 "unexpected error: {err}"
1479 );
1480 }
1481
1482 /// `version` is required — a projection file without it refuses.
1483 #[test]
1484 fn version_is_required() {
1485 let src = r#"{
1486 "destination_mem": "m",
1487 "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1488 }"#;
1489 assert!(serde_json::from_str::<Binding>(src).is_err());
1490 }
1491
1492 // ---- hash(D) --------------------------------------------------------
1493
1494 /// `hash(D)` is stable and recomputable: the same binding hashes
1495 /// identically, and the digest is 64 lowercase hex chars.
1496 #[test]
1497 fn hash_is_stable_and_recomputable() {
1498 let b = binding();
1499 let h1 = hash_binding(&b);
1500 let h2 = hash_binding(&b);
1501 assert_eq!(h1, h2);
1502 assert_eq!(h1.len(), 64);
1503 assert!(
1504 h1.chars()
1505 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1506 );
1507 }
1508
1509 /// Changing a source's selection pattern — now an input *inside* the one
1510 /// record — changes the hash.
1511 #[test]
1512 fn changing_a_source_pattern_changes_the_hash() {
1513 let base = hash_binding(&binding());
1514 let mut changed = binding();
1515 changed.sources[0].scope = vec![allow("../public/**/*.md")];
1516 assert_ne!(base, hash_binding(&changed));
1517 }
1518
1519 /// Changing a source's pointer changes the hash.
1520 #[test]
1521 fn changing_a_source_pointer_changes_the_hash() {
1522 let base = hash_binding(&binding());
1523 let mut changed = binding();
1524 changed.sources[0].pointer = "../elsewhere".to_string();
1525 assert_ne!(base, hash_binding(&changed));
1526 }
1527
1528 /// Changing `trigger`, `batch_size`, or `post_actions` does **not** change
1529 /// the hash — scheduling never changes what the mem claims. Neither does a
1530 /// source's `engagement` contract (the pre-consolidation exclusion carried
1531 /// forward).
1532 #[test]
1533 fn scheduling_knobs_do_not_change_the_hash() {
1534 let base = hash_binding(&binding());
1535
1536 let mut b_trigger = binding();
1537 b_trigger.operations.build.as_mut().unwrap().trigger = IngestTrigger::Manual;
1538 assert_eq!(base, hash_binding(&b_trigger), "trigger is excluded");
1539
1540 let mut b_batch = binding();
1541 b_batch.operations.build.as_mut().unwrap().batch_size = 999;
1542 assert_eq!(base, hash_binding(&b_batch), "batch_size is excluded");
1543
1544 let mut b_post = binding();
1545 b_post.operations.build.as_mut().unwrap().post_actions =
1546 Some(serde_json::json!({ "archive_source": false }));
1547 assert_eq!(base, hash_binding(&b_post), "post_actions is excluded");
1548
1549 // The sync/verify blocks are excluded too.
1550 let mut b_sync = binding();
1551 b_sync.operations.sync = None;
1552 assert_eq!(base, hash_binding(&b_sync), "sync block is excluded");
1553
1554 // A source's engagement contract is excluded.
1555 let mut b_engage = binding();
1556 b_engage.sources[0].engagement = Some(serde_json::json!({ "readVerb": "Study" }));
1557 assert_eq!(base, hash_binding(&b_engage), "engagement is excluded");
1558 }
1559
1560 /// Changing `operations.build.mode` — a content-defining input — **does**
1561 /// change the hash.
1562 #[test]
1563 fn changing_build_mode_changes_the_hash() {
1564 let base = hash_binding(&binding());
1565 let mut b = binding();
1566 b.operations.build.as_mut().unwrap().mode = BuildMode::OneShot;
1567 assert_ne!(base, hash_binding(&b));
1568 }
1569
1570 /// An absent `build` block deserializes (serde default) and still hashes —
1571 /// the build mode simply does not participate in `hash(D)`.
1572 #[test]
1573 fn absent_build_deserializes_and_hashes() {
1574 let src = r#"{
1575 "version": 2,
1576 "destination_mem": "m",
1577 "operations": { "verify": { "trigger": "manual", "batch_size": 5 } }
1578 }"#;
1579 let b: Binding = serde_json::from_str(src).unwrap();
1580 assert!(b.operations.build.is_none(), "absent build parses to None");
1581 let h = hash_binding(&b);
1582 assert_eq!(h.len(), 64);
1583 }
1584
1585 // ---- capability matrix + validate -----------------------------------
1586
1587 /// The matrix rows are unchanged by the consolidation.
1588 #[test]
1589 fn capability_matrix_rows() {
1590 let web = medium_capabilities(MediumType::Web);
1591 assert!(!web.enumerable && !web.change_signal && !web.base_version_retrievable);
1592 assert!(!web.glob_deny_legal);
1593 assert_eq!(web.anchor_namespace, "url");
1594
1595 let graph = medium_capabilities(MediumType::Graph);
1596 assert!(graph.enumerable && graph.change_signal && graph.base_version_retrievable);
1597 assert!(!graph.glob_deny_legal, "graph namespace is not path-shaped");
1598 assert_eq!(graph.anchor_namespace, "entity");
1599
1600 for ty in [
1601 MediumType::Codebase,
1602 MediumType::Filesystem,
1603 MediumType::Git,
1604 ] {
1605 let c = medium_capabilities(ty);
1606 assert!(c.enumerable && c.change_signal && c.base_version_retrievable);
1607 assert!(c.glob_deny_legal, "{ty:?} allows glob deny_paths");
1608 }
1609 assert_eq!(
1610 medium_capabilities(MediumType::Git).anchor_namespace,
1611 "path+commit"
1612 );
1613 }
1614
1615 /// An empty source name refuses, and a duplicate source name refuses —
1616 /// per-source state keys must be present and collision-free.
1617 #[test]
1618 fn empty_and_duplicate_source_names_refuse() {
1619 let mut b = binding();
1620 b.deny_paths.clear();
1621 b.sources = vec![
1622 source("", MediumType::Codebase, "../a", vec![], None, None),
1623 source("dup", MediumType::Codebase, "../b", vec![], None, None),
1624 source("dup", MediumType::Codebase, "../c", vec![], None, None),
1625 ];
1626 let errs = validate_binding(&b).unwrap_err();
1627 assert!(
1628 errs.iter()
1629 .any(|e| matches!(e, CapabilityError::EmptySourceName)),
1630 "expected EmptySourceName, got {errs:?}"
1631 );
1632 assert!(
1633 errs.iter().any(|e| matches!(
1634 e,
1635 CapabilityError::DuplicateSourceName { name } if name == "dup"
1636 )),
1637 "expected DuplicateSourceName, got {errs:?}"
1638 );
1639 }
1640
1641 /// `sync` and `verify` over a `web` source each refuse as out-of-scope.
1642 #[test]
1643 fn sync_and_verify_over_web_refuse() {
1644 // Web binding, no deny_paths (globs illegal), no prep — isolate the op refusal.
1645 let mut b = binding();
1646 b.deny_paths.clear();
1647 b.sources = vec![source(
1648 "web-source",
1649 MediumType::Web,
1650 "https://example.com",
1651 vec![],
1652 None,
1653 None,
1654 )];
1655 let errs = validate_binding(&b).unwrap_err();
1656 let ops: Vec<&str> = errs
1657 .iter()
1658 .filter_map(|e| match e {
1659 CapabilityError::OperationOutOfScope { operation, .. } => Some(*operation),
1660 _ => None,
1661 })
1662 .collect();
1663 assert!(ops.contains(&"sync"), "sync refused: {errs:?}");
1664 assert!(ops.contains(&"verify"), "verify refused: {errs:?}");
1665 }
1666
1667 /// Glob `deny_paths` over a `graph` source refuses.
1668 #[test]
1669 fn glob_deny_over_graph_refuses() {
1670 let mut b = binding();
1671 b.operations.sync = None;
1672 b.operations.verify = None;
1673 b.deny_paths = vec!["some/**".to_string()];
1674 b.sources = vec![source(
1675 "graph-source",
1676 MediumType::Graph,
1677 "home",
1678 vec![],
1679 None,
1680 None,
1681 )];
1682 let errs = validate_binding(&b).unwrap_err();
1683 assert!(
1684 errs.iter()
1685 .any(|e| matches!(e, CapabilityError::GlobDenyIllegal { .. })),
1686 "expected GlobDenyIllegal, got {errs:?}"
1687 );
1688 }
1689
1690 /// A source preparation the registry does not know refuses at
1691 /// validation time — the narrowed refusal: same error shape, "not in
1692 /// this engine's registry" semantics, the registered set named.
1693 #[test]
1694 fn unregistered_preparation_refuses() {
1695 let mut b = binding();
1696 b.operations.sync = None;
1697 b.operations.verify = None;
1698 b.deny_paths.clear();
1699 b.sources = vec![source(
1700 "manual-pages",
1701 MediumType::Filesystem,
1702 "../docs",
1703 vec![],
1704 Some("pdf-to-markdown"),
1705 None,
1706 )];
1707 let errs = validate_binding(&b).unwrap_err();
1708 let refusal = errs
1709 .iter()
1710 .find(|e| matches!(
1711 e,
1712 CapabilityError::PreparationUnsupported { preparation, impl_version, .. }
1713 if preparation == "pdf-to-markdown" && *impl_version == PREPARATION_IMPL_VERSION
1714 ))
1715 .unwrap_or_else(|| panic!("expected PreparationUnsupported, got {errs:?}"));
1716 let msg = refusal.to_string();
1717 assert!(
1718 msg.contains("not in this engine's preparation registry"),
1719 "{msg}"
1720 );
1721 assert!(
1722 msg.contains("entity-load-bearing"),
1723 "names the registered set: {msg}"
1724 );
1725 assert!(
1726 !msg.contains("facet"),
1727 "the retired noun stays retired: {msg}"
1728 );
1729 }
1730
1731 /// A registered preparation validates clean over a medium whose anchor
1732 /// namespace admits its grain (`entity-load-bearing` over `graph`), and
1733 /// refuses over one that does not (the same identifier over `codebase`,
1734 /// where no entity-grain anchor could ever meet it).
1735 #[test]
1736 fn registered_preparation_validates_over_its_namespace_only() {
1737 let mut ok = binding();
1738 ok.deny_paths.clear();
1739 ok.sources = vec![source(
1740 "claims",
1741 MediumType::Graph,
1742 "home",
1743 vec![allow("*")],
1744 Some(crate::preparation::ENTITY_LOAD_BEARING),
1745 None,
1746 )];
1747 assert!(
1748 validate_binding(&ok).is_ok(),
1749 "registered preparation over its namespace validates clean: {:?}",
1750 validate_binding(&ok)
1751 );
1752
1753 let mut mismatch = binding();
1754 mismatch.sources = vec![source(
1755 "source-tree",
1756 MediumType::Codebase,
1757 "../public",
1758 vec![allow("**/*.rs")],
1759 Some(crate::preparation::ENTITY_LOAD_BEARING),
1760 None,
1761 )];
1762 let errs = validate_binding(&mismatch).unwrap_err();
1763 assert!(
1764 errs.iter().any(|e| matches!(
1765 e,
1766 CapabilityError::PreparationGrainMismatch { preparation, anchor_namespace, .. }
1767 if preparation == crate::preparation::ENTITY_LOAD_BEARING && *anchor_namespace == "path"
1768 )),
1769 "expected PreparationGrainMismatch, got {errs:?}"
1770 );
1771 assert!(
1772 !errs
1773 .iter()
1774 .any(|e| matches!(e, CapabilityError::PreparationUnsupported { .. })),
1775 "a registered identifier is never reported as unregistered"
1776 );
1777 }
1778
1779 /// The impl version is hashed for EVERY source, with or without a
1780 /// declared preparation: the hash a prior engine generation computed
1781 /// (impl version 0) differs from the live one, so every finding keyed on
1782 /// it is invalidated by construction when the constant bumps.
1783 #[test]
1784 fn impl_version_is_hashed_into_every_binding() {
1785 let plain = binding();
1786 assert!(plain.sources.iter().all(|s| s.preparation.is_none()));
1787 let live = hash_binding(&plain);
1788 assert_eq!(
1789 live,
1790 hash_binding_at_impl_version(&plain, PREPARATION_IMPL_VERSION)
1791 );
1792 assert_ne!(
1793 live,
1794 hash_binding_at_impl_version(&plain, 0),
1795 "the pre-registry generation's hash differs from the live one"
1796 );
1797 assert_ne!(
1798 live,
1799 hash_binding_at_impl_version(&plain, PREPARATION_IMPL_VERSION + 1)
1800 );
1801
1802 let mut prepared = plain.clone();
1803 prepared.sources[0].preparation = Some(crate::preparation::ENTITY_LOAD_BEARING.to_string());
1804 assert_ne!(
1805 hash_binding(&prepared),
1806 live,
1807 "the identifier is hashed too"
1808 );
1809 }
1810
1811 /// Every combination the matrix marks legal validates clean:
1812 /// codebase / filesystem / git / graph bindings with build+sync+verify all
1813 /// pass (graph carries no glob deny_paths, none carry preparation).
1814 #[test]
1815 fn legal_combinations_validate_clean() {
1816 // codebase / filesystem / git — path-shaped, deny_paths legal.
1817 for ty in [
1818 MediumType::Codebase,
1819 MediumType::Filesystem,
1820 MediumType::Git,
1821 ] {
1822 let mut b = binding();
1823 b.sources = vec![source(
1824 "f",
1825 ty,
1826 "../src",
1827 vec![allow("../src/**")],
1828 None,
1829 None,
1830 )];
1831 assert!(
1832 validate_binding(&b).is_ok(),
1833 "{ty:?} build+sync+verify should validate clean"
1834 );
1835 }
1836 // graph — build+sync+verify legal, but only without glob deny_paths.
1837 let mut graph_binding = binding();
1838 graph_binding.deny_paths.clear();
1839 graph_binding.sources = vec![source("g", MediumType::Graph, "home", vec![], None, None)];
1840 assert!(
1841 validate_binding(&graph_binding).is_ok(),
1842 "graph build+sync+verify with no glob deny should validate clean"
1843 );
1844 }
1845
1846 // ---- F1: prune guarantee -------------------------------------------
1847
1848 /// F1 — the `prune` block is additive: a binding without it deserializes
1849 /// to `prune: None`, and a block that sets a guarantee round-trips
1850 /// (defaulting to `conflict-flag` when the guarantee is absent).
1851 #[test]
1852 fn prune_block_is_additive_and_round_trips() {
1853 let src = r#"{
1854 "version": 2,
1855 "destination_mem": "m",
1856 "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1857 }"#;
1858 let b: Binding = serde_json::from_str(src).unwrap();
1859 assert!(b.prune.is_none(), "absent prune parses to None");
1860
1861 // A prune block with no guarantee defaults to conflict-flag.
1862 let with_default = r#"{
1863 "version": 2,
1864 "destination_mem": "m",
1865 "prune": {},
1866 "operations": { "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 } }
1867 }"#;
1868 let b: Binding = serde_json::from_str(with_default).unwrap();
1869 assert_eq!(
1870 b.prune.as_ref().unwrap().guarantee,
1871 PruneGuarantee::ConflictFlag
1872 );
1873
1874 // Explicit never-clobber round-trips.
1875 let explicit = PruneConfig {
1876 guarantee: PruneGuarantee::NeverClobber,
1877 };
1878 let json = serde_json::to_string(&explicit).unwrap();
1879 assert!(json.contains("never-clobber"));
1880 assert_eq!(
1881 serde_json::from_str::<PruneConfig>(&json).unwrap(),
1882 explicit
1883 );
1884 }
1885
1886 /// F1 — the `prune` policy never changes `hash(D)` (it is a maintenance
1887 /// policy, excluded like the sync/verify blocks).
1888 #[test]
1889 fn prune_does_not_change_the_hash() {
1890 let base = hash_binding(&binding());
1891 let mut pruned = binding();
1892 pruned.prune = Some(PruneConfig {
1893 guarantee: PruneGuarantee::NeverClobber,
1894 });
1895 assert_eq!(
1896 base,
1897 hash_binding(&pruned),
1898 "prune policy is excluded from hash(D)"
1899 );
1900 }
1901
1902 /// F1 — the strongest guarantee a medium supports is base-leg-retrievability:
1903 /// git-backed mediums support never-clobber; `web` supports only conflict-flag.
1904 #[test]
1905 fn prune_guarantee_per_medium_matches_capability_matrix() {
1906 for ty in [
1907 MediumType::Codebase,
1908 MediumType::Filesystem,
1909 MediumType::Git,
1910 MediumType::Graph,
1911 ] {
1912 assert_eq!(
1913 prune_guarantee_for_medium(ty),
1914 PruneGuarantee::NeverClobber,
1915 "{ty:?} can retrieve a base leg → never-clobber"
1916 );
1917 }
1918 assert_eq!(
1919 prune_guarantee_for_medium(MediumType::Web),
1920 PruneGuarantee::ConflictFlag,
1921 "web has no retrievable base leg → conflict-flag only"
1922 );
1923 }
1924
1925 /// F1 REFUSAL — requesting `never-clobber` prune over a `web` source (no
1926 /// retrievable base leg) fails at binding validation with a remedy-bearing
1927 /// error naming the downgrade, never a runtime surprise.
1928 #[test]
1929 fn never_clobber_prune_over_web_refuses_with_remedy() {
1930 let mut b = binding();
1931 b.operations.sync = None; // isolate the prune refusal from op-out-of-scope
1932 b.operations.verify = None;
1933 b.deny_paths.clear();
1934 b.prune = Some(PruneConfig {
1935 guarantee: PruneGuarantee::NeverClobber,
1936 });
1937 b.sources = vec![source(
1938 "web-source",
1939 MediumType::Web,
1940 "https://example.com",
1941 vec![],
1942 None,
1943 None,
1944 )];
1945 let errs = validate_binding(&b).unwrap_err();
1946 let refusal = errs
1947 .iter()
1948 .find_map(|e| match e {
1949 CapabilityError::PruneGuaranteeUnsupported {
1950 requested,
1951 supported,
1952 ..
1953 } => Some((*requested, *supported)),
1954 _ => None,
1955 })
1956 .expect("expected a PruneGuaranteeUnsupported refusal");
1957 assert_eq!(refusal, ("never-clobber", "conflict-flag"));
1958 // The message carries the concrete downgrade remedy.
1959 let msg = errs
1960 .iter()
1961 .find(|e| matches!(e, CapabilityError::PruneGuaranteeUnsupported { .. }))
1962 .unwrap()
1963 .to_string();
1964 assert!(
1965 msg.contains("conflict-flag"),
1966 "remedy names the downgrade: {msg}"
1967 );
1968 }
1969
1970 /// F1 — `never-clobber` over a git-backed source validates clean, and
1971 /// `conflict-flag` (the always-supportable degradation) validates clean over
1972 /// `web` — the guarantee the matrix marks legal is accepted.
1973 #[test]
1974 fn prune_guarantee_supported_validates_clean() {
1975 // never-clobber over codebase — base retrievable, clean.
1976 let mut nc = binding();
1977 nc.prune = Some(PruneConfig {
1978 guarantee: PruneGuarantee::NeverClobber,
1979 });
1980 assert!(validate_binding(&nc).is_ok());
1981
1982 // conflict-flag over web — always supportable (build-only to isolate).
1983 let mut cf = binding();
1984 cf.operations.sync = None;
1985 cf.operations.verify = None;
1986 cf.deny_paths.clear();
1987 cf.prune = Some(PruneConfig {
1988 guarantee: PruneGuarantee::ConflictFlag,
1989 });
1990 cf.sources = vec![source(
1991 "web-source",
1992 MediumType::Web,
1993 "https://example.com",
1994 vec![],
1995 None,
1996 None,
1997 )];
1998 assert!(validate_binding(&cf).is_ok());
1999 }
2000
2001 /// A `web` binding scaffolded build-only (no sync/verify, no deny, no prep)
2002 /// validates clean — the matrix-filtered default.
2003 #[test]
2004 fn web_build_only_validates_clean() {
2005 let mut b = binding();
2006 b.operations.sync = None;
2007 b.operations.verify = None;
2008 b.deny_paths.clear();
2009 b.sources = vec![source(
2010 "web-source",
2011 MediumType::Web,
2012 "https://example.com",
2013 vec![],
2014 None,
2015 None,
2016 )];
2017 assert!(validate_binding(&b).is_ok());
2018 }
2019
2020 // ---- coverage semantics: resolution / refusal / hash stability ------
2021
2022 /// A clean web source carries NO scope: the medium has no scope
2023 /// vocabulary, so any rule on it is uninterpretable and refuses. This
2024 /// helper used to hand out `**/*` — which made every web fixture carry a
2025 /// decorative rule, and is why the defect went unnoticed here.
2026 fn web_source(name: &str) -> Source {
2027 source(
2028 name,
2029 MediumType::Web,
2030 "https://example.test",
2031 vec![],
2032 None,
2033 None,
2034 )
2035 }
2036
2037 /// Resolution: an undeclared field resolves per binding — all
2038 /// sources enumerable → exhaustive; at least one non-enumerable
2039 /// source → curated (a mixed binding claims the weaker of its
2040 /// parts). An explicit `curated` validates over any medium and
2041 /// resolves to curated, declared.
2042 #[test]
2043 fn coverage_resolves_per_medium_when_undeclared() {
2044 let enumerable = binding();
2045 assert_eq!(enumerable.coverage_semantics, None);
2046 let eff = effective_coverage_semantics(&enumerable);
2047 assert_eq!(eff.value, CoverageSemantics::Exhaustive);
2048 assert!(!eff.declared, "resolved, not declared");
2049 validate_binding(&enumerable).expect("undeclared over enumerable validates");
2050
2051 // Mixed: one enumerable + one web source → curated.
2052 let mut mixed = binding();
2053 mixed.sources.push(web_source("front"));
2054 // web has no change signal — drop sync/verify so only coverage
2055 // resolution is under test.
2056 mixed.operations.sync = None;
2057 mixed.operations.verify = None;
2058 mixed.deny_paths.clear();
2059 let eff = effective_coverage_semantics(&mixed);
2060 assert_eq!(eff.value, CoverageSemantics::Curated);
2061 assert!(!eff.declared);
2062 validate_binding(&mixed).expect("undeclared over web validates (resolves, never refuses)");
2063
2064 // Explicit curated over any medium: validates, declared.
2065 let mut curated = mixed.clone();
2066 curated.coverage_semantics = Some(CoverageSemantics::Curated);
2067 validate_binding(&curated).expect("explicit curated validates over any medium");
2068 let eff = effective_coverage_semantics(&curated);
2069 assert_eq!(eff.value, CoverageSemantics::Curated);
2070 assert!(eff.declared);
2071 }
2072
2073 /// Refusal: an explicit `exhaustive` with at least one
2074 /// non-enumerable source refuses, naming the source, the medium,
2075 /// and `curated` as the remedy — alongside other refusals of the
2076 /// same binding, not replacing them. Complements: a binding whose
2077 /// ONLY problem is this one still reports it; an explicit
2078 /// `exhaustive` over enumerable sources is NOT refused.
2079 #[test]
2080 fn explicit_exhaustive_over_non_enumerable_refuses() {
2081 // Only-problem case: clean web binding, explicit exhaustive.
2082 let mut only = binding();
2083 only.sources = vec![web_source("front")];
2084 only.operations.sync = None;
2085 only.operations.verify = None;
2086 only.deny_paths.clear();
2087 only.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2088 let errs = validate_binding(&only).expect_err("must refuse");
2089 assert_eq!(errs.len(), 1, "only this refusal: {errs:?}");
2090 match &errs[0] {
2091 CapabilityError::CoverageExhaustiveUnsupported {
2092 source_name,
2093 medium_type,
2094 } => {
2095 assert_eq!(source_name, "front");
2096 assert_eq!(medium_type, "web");
2097 }
2098 other => panic!("expected CoverageExhaustiveUnsupported, got {other:?}"),
2099 }
2100 let msg = errs[0].to_string();
2101 assert!(
2102 msg.contains("'front'") && msg.contains("'web'") && msg.contains("curated"),
2103 "refusal names source, medium, and the curated remedy: {msg}"
2104 );
2105
2106 // Alongside other refusals: keep sync declared (web has no change
2107 // signal) — both refusals must be reported together.
2108 let mut multi = binding();
2109 multi.sources = vec![web_source("front")];
2110 multi.operations.verify = None;
2111 multi.deny_paths.clear();
2112 multi.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2113 assert!(multi.operations.sync.is_some(), "fixture declares sync");
2114 let errs = validate_binding(&multi).expect_err("must refuse");
2115 assert!(
2116 errs.iter()
2117 .any(|e| matches!(e, CapabilityError::CoverageExhaustiveUnsupported { .. })),
2118 "coverage refusal present: {errs:?}"
2119 );
2120 assert!(
2121 errs.iter()
2122 .any(|e| matches!(e, CapabilityError::OperationOutOfScope { .. })),
2123 "reported alongside the sync refusal, not replacing it: {errs:?}"
2124 );
2125
2126 // Complement: explicit exhaustive over enumerable is NOT refused.
2127 let mut ok = binding();
2128 ok.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2129 validate_binding(&ok).expect("explicit exhaustive over enumerable validates");
2130 }
2131
2132 /// Hash stability: the hash serialises the RESOLVED value, never
2133 /// the `Option`. Over enumerable sources, an undeclared field
2134 /// hashes byte-identically to an explicit `exhaustive` (== the
2135 /// pre-optionality bytes, whose serialized projection was the
2136 /// same `"exhaustive"` value). Over a non-enumerable source, an
2137 /// undeclared field hashes identically to an explicit `curated`
2138 /// (the moved-once, stable-thereafter hash) and differently from
2139 /// the enumerable case's resolution.
2140 #[test]
2141 fn hash_serialises_the_resolved_coverage_value() {
2142 // Enumerable: None == Some(Exhaustive), byte-for-byte.
2143 let undeclared = binding();
2144 let mut declared = binding();
2145 declared.coverage_semantics = Some(CoverageSemantics::Exhaustive);
2146 assert_eq!(
2147 hash_binding(&undeclared),
2148 hash_binding(&declared),
2149 "undeclared over enumerable keeps the pre-optionality hash"
2150 );
2151 // ...and an explicit curated moves it (a genuine coverage change).
2152 let mut curated = binding();
2153 curated.coverage_semantics = Some(CoverageSemantics::Curated);
2154 assert_ne!(hash_binding(&undeclared), hash_binding(&curated));
2155
2156 // Non-enumerable: None == Some(Curated) — the one-time move is
2157 // to the curated hash, stable thereafter.
2158 let mut web_undeclared = binding();
2159 web_undeclared.sources = vec![web_source("front")];
2160 let mut web_curated = web_undeclared.clone();
2161 web_curated.coverage_semantics = Some(CoverageSemantics::Curated);
2162 assert_eq!(
2163 hash_binding(&web_undeclared),
2164 hash_binding(&web_curated),
2165 "undeclared over web resolves (and hashes) as curated"
2166 );
2167 }
2168}