Skip to main content

memstead_base/
anchor.rs

1//! Anchors — engine-owned durable provenance records tying an entity to
2//! the source artifacts it describes.
3//!
4//! An anchor is the projection pipeline's single new load-bearing
5//! primitive: which artifact (in the medium's own namespace), at which
6//! *grain*, under which *provenance class*, at which medium-typed
7//! *version*, hashed over the **prepared** artifact form (never raw
8//! bytes) where the class carries hash semantics, and the medium's
9//! declared *hash stability* — so an unstable-source hash break resolves
10//! as [`AnchorState::Recheck`], not [`AnchorState::Drifted`].
11//!
12//! ## Naming
13//!
14//! The `Anchor*` family is deliberately distinct from the three
15//! provenance-adjacent type families already in the tree — it never
16//! reuses `Provenance` / `ProvenanceKind` (the mutation-log record in
17//! [`crate::provenance`]), nor `ArchiveProvenance` / `EntityProvenance` /
18//! `History` (the authoring-provenance payload in
19//! [`memstead_schema::archive_provenance`]). Those stay; anchors are a
20//! separate concern (source→entity provenance, not mutation history nor
21//! authoring lineage).
22//!
23//! ## Wire vocabulary (fixed contract)
24//!
25//! - provenance classes: `anchored` / `derived` / `authored` /
26//!   `informed-by`
27//! - grains: `span` / `file` / `tree` / `url` / `entity`
28//! - hash stability: `stable` / `unstable`
29//! - resolution states: `resolves` / `drifted` / `recheck` / `orphaned`
30//!
31//! The Rust identifiers around this vocabulary are the implementer's
32//! choice; the wire strings are the contract and are locked by the
33//! `*_wire_strings_are_stable` tests below.
34//!
35//! ## Storage
36//!
37//! Anchors persist in an engine-owned sidecar on the mem branch under
38//! [`ANCHOR_SIDECAR_PATH`] (`.memstead/anchors.json`) — see
39//! [`AnchorSidecar`]. The sidecar is written only through engine commits
40//! (the [`crate::backend::MemBackend`] sidecar seam); every external
41//! reader already filters the `.memstead/` namespace, so an anchor-only
42//! commit yields no entity deltas and does not participate in `_hash`.
43//!
44//! ## Scope of this module
45//!
46//! Pure value types, wire (de)serialisation, validation (typed
47//! `INVALID_ANCHOR` refusals with recovery detail), and the resolution
48//! model. No storage or IO lives here — the backend seam and the
49//! mutation/CLI wiring consume these types.
50
51use std::collections::BTreeMap;
52
53use serde::{Deserialize, Serialize};
54
55/// Mem-relative path of the engine-owned anchors sidecar on the mem
56/// branch. Lives under the `.memstead/` umbrella every external reader
57/// already treats as non-entity, so an anchor-only commit produces zero
58/// entity deltas.
59pub const ANCHOR_SIDECAR_PATH: &str = ".memstead/anchors.json";
60
61/// Current sidecar document schema version.
62pub const ANCHOR_SIDECAR_VERSION: u32 = 1;
63
64/// Stable typed error code returned when an `anchors[]` element is
65/// malformed. Mirrors the engine's other typed-envelope codes; the whole
66/// mutation refuses and the entity is not written.
67pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
68
69// ---------------------------------------------------------------------------
70// Provenance class
71// ---------------------------------------------------------------------------
72
73/// The epistemic standing of an anchor — how the entity relates to the
74/// artifact it references.
75///
76/// - [`Anchored`](Self::Anchored) — the entity directly reflects specific
77///   artifact content (carries hash semantics).
78/// - [`Derived`](Self::Derived) — the entity was computed/synthesised from
79///   one or more input artifacts (carries hash semantics; lists inputs).
80/// - [`Authored`](Self::Authored) — a human/agent authored the entity with
81///   the artifact in view (no hash semantics; excluded from drift
82///   adjudication).
83/// - [`InformedBy`](Self::InformedBy) — the artifact informed the entity
84///   without a content-fidelity claim (no hash semantics; excluded from
85///   drift adjudication).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum AnchorProvenanceClass {
89    Anchored,
90    Derived,
91    Authored,
92    InformedBy,
93}
94
95impl AnchorProvenanceClass {
96    /// Every wire string, in declaration order — the allowed set a
97    /// refusal echoes for recovery.
98    pub const WIRE_VALUES: &'static [&'static str] =
99        &["anchored", "derived", "authored", "informed-by"];
100
101    /// Stable wire form.
102    pub fn as_wire(&self) -> &'static str {
103        match self {
104            AnchorProvenanceClass::Anchored => "anchored",
105            AnchorProvenanceClass::Derived => "derived",
106            AnchorProvenanceClass::Authored => "authored",
107            AnchorProvenanceClass::InformedBy => "informed-by",
108        }
109    }
110
111    /// Inverse of [`Self::as_wire`]; `None` for an unknown string so the
112    /// validator can refuse it typed rather than misclassify.
113    pub fn from_wire(s: &str) -> Option<Self> {
114        match s {
115            "anchored" => Some(AnchorProvenanceClass::Anchored),
116            "derived" => Some(AnchorProvenanceClass::Derived),
117            "authored" => Some(AnchorProvenanceClass::Authored),
118            "informed-by" => Some(AnchorProvenanceClass::InformedBy),
119            _ => None,
120        }
121    }
122
123    /// Whether this class carries hash semantics. `anchored` and
124    /// `derived` assert content fidelity and participate in hash-drift
125    /// adjudication; `authored` and `informed-by` do not — a content
126    /// change under them produces no drift state, and supplying a hash on
127    /// them is a validation refusal.
128    pub fn is_hash_bearing(&self) -> bool {
129        matches!(
130            self,
131            AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
132        )
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Grain
138// ---------------------------------------------------------------------------
139
140/// The granularity of the artifact reference an anchor carries.
141///
142/// `span` / `file` / `tree` select within a path-shaped namespace; `url`
143/// selects a web resource; `entity` selects another mem's entity. The
144/// medium-capability matrix ([`crate::binding::medium_capabilities`])
145/// decides which grains a given medium's namespace can support — a
146/// mismatch (e.g. `span` on a `url`-namespace medium) refuses typed at
147/// validation.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum AnchorGrain {
151    Span,
152    File,
153    Tree,
154    Url,
155    Entity,
156}
157
158impl AnchorGrain {
159    /// Every wire string, in declaration order.
160    pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
161
162    /// Stable wire form.
163    pub fn as_wire(&self) -> &'static str {
164        match self {
165            AnchorGrain::Span => "span",
166            AnchorGrain::File => "file",
167            AnchorGrain::Tree => "tree",
168            AnchorGrain::Url => "url",
169            AnchorGrain::Entity => "entity",
170        }
171    }
172
173    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
174    pub fn from_wire(s: &str) -> Option<Self> {
175        match s {
176            "span" => Some(AnchorGrain::Span),
177            "file" => Some(AnchorGrain::File),
178            "tree" => Some(AnchorGrain::Tree),
179            "url" => Some(AnchorGrain::Url),
180            "entity" => Some(AnchorGrain::Entity),
181            _ => None,
182        }
183    }
184
185    /// Whether this grain can be expressed in the medium's declared anchor
186    /// namespace (the `anchor_namespace` string from the E2 capability
187    /// matrix: `path` / `path+commit` / `entity` / `url`).
188    ///
189    /// - `span` / `file` / `tree` require a path-shaped namespace
190    ///   (`path` or `path+commit`);
191    /// - `url` requires the `url` namespace;
192    /// - `entity` requires the `entity` namespace.
193    pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
194        let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
195        match self {
196            AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
197            AnchorGrain::Url => anchor_namespace == "url",
198            AnchorGrain::Entity => anchor_namespace == "entity",
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Hash stability
205// ---------------------------------------------------------------------------
206
207/// The medium's declared hash stability — whether a change in the
208/// prepared-content hash is a reliable drift signal.
209///
210/// A `stable` medium's hash break resolves [`AnchorState::Drifted`]; an
211/// `unstable` medium's hash break resolves [`AnchorState::Recheck`]
212/// (the hash may have moved for reasons unrelated to the entity's claim,
213/// so the engine flags it for re-examination rather than asserting drift).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "lowercase")]
216pub enum AnchorHashStability {
217    Stable,
218    Unstable,
219}
220
221impl AnchorHashStability {
222    /// Every wire string.
223    pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
224
225    /// Stable wire form.
226    pub fn as_wire(&self) -> &'static str {
227        match self {
228            AnchorHashStability::Stable => "stable",
229            AnchorHashStability::Unstable => "unstable",
230        }
231    }
232
233    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
234    pub fn from_wire(s: &str) -> Option<Self> {
235        match s {
236            "stable" => Some(AnchorHashStability::Stable),
237            "unstable" => Some(AnchorHashStability::Unstable),
238            _ => None,
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Medium-typed version
245// ---------------------------------------------------------------------------
246
247/// A medium-typed pinned version the anchor was recorded against.
248///
249/// Which variant applies follows from the medium's namespace: a git /
250/// `path+commit` medium pins a [`Commit`](Self::Commit); a graph / `entity`
251/// medium pins a [`Snapshot`](Self::Snapshot) token; a web / `url` medium
252/// pins an [`Etag`](Self::Etag). A plain `path` medium (mtime change
253/// signal, no retrievable version) records **absent** — represented as
254/// `None` on [`Anchor::at_version`], never a variant here.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
257pub enum AnchorVersion {
258    /// A git commit id (`path+commit` / git namespace).
259    Commit(String),
260    /// A graph snapshot token (`entity` namespace).
261    Snapshot(String),
262    /// A web ETag (`url` namespace).
263    Etag(String),
264}
265
266// ---------------------------------------------------------------------------
267// Anchor
268// ---------------------------------------------------------------------------
269
270/// One durable anchor record: an entity's provenance tie to a single
271/// source artifact.
272///
273/// This is the persisted + read shape. Malformed wire input is refused
274/// upstream via [`AnchorInput::validate`], which produces this strict type
275/// only when every rule holds.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct Anchor {
278    /// Artifact reference in the medium's own namespace — a repo-relative
279    /// path, a `path@commit`, a URL, or an entity id, interpreted per
280    /// [`Self::grain`] and the medium.
281    pub artifact: String,
282    /// The granularity of [`Self::artifact`].
283    pub grain: AnchorGrain,
284    /// The anchor's epistemic standing.
285    pub class: AnchorProvenanceClass,
286    /// The medium-typed pinned version, or `None` when the medium has no
287    /// retrievable version (plain `path` / mtime).
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub at_version: Option<AnchorVersion>,
290    /// Content hash over the **prepared** artifact form (never raw bytes),
291    /// present only when [`Self::class`] carries hash semantics. `None`
292    /// for `authored` / `informed-by`.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub hash: Option<String>,
295    /// The medium's declared hash stability — governs whether a hash break
296    /// resolves `drifted` or `recheck`.
297    pub hash_stability: AnchorHashStability,
298    /// For a `derived` class: the input artifact refs the entity was
299    /// derived from. Empty for every other class.
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub derived_from: Vec<String>,
302    /// `hash(D)` of the binding that produced this anchor (E2), when a
303    /// binding produced it. `None` for a manually-authored anchor with no
304    /// producing binding.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub binding: Option<String>,
307    /// The NAME of the source (as declared in the producing binding's
308    /// `sources[]`) that produced this anchor — so a discovery run can
309    /// be measured per entry point. Optional and additive: pre-existing
310    /// sidecars load unchanged and are never backfilled (a guessed
311    /// provenance is worse than an absent one). Validated against the
312    /// producing binding's declared names only when [`Self::binding`]
313    /// still resolves in the workspace.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub source: Option<String>,
316}
317
318// ---------------------------------------------------------------------------
319// Validation
320// ---------------------------------------------------------------------------
321
322/// A permissive wire-shaped anchor element as it arrives on a mutation's
323/// `anchors[]` parameter. All fields are optional / string-typed so an
324/// unknown class or grain surfaces as a typed [`AnchorValidationError`]
325/// with recovery detail rather than an opaque serde failure. Call
326/// [`Self::validate`] to obtain a strict [`Anchor`].
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct AnchorInput {
329    #[serde(default)]
330    pub artifact: Option<String>,
331    #[serde(default)]
332    pub grain: Option<String>,
333    #[serde(default)]
334    pub class: Option<String>,
335    #[serde(default)]
336    pub at_version: Option<AnchorVersion>,
337    #[serde(default)]
338    pub hash: Option<String>,
339    #[serde(default)]
340    pub hash_stability: Option<String>,
341    #[serde(default)]
342    pub derived_from: Option<Vec<String>>,
343    #[serde(default)]
344    pub binding: Option<String>,
345    #[serde(default)]
346    pub source: Option<String>,
347}
348
349/// A permissive wire-shaped `anchors_unset[]` element — an explicit
350/// removal selector on the update surface. Each entry names an `artifact`
351/// and may narrow by `grain` and/or `class`; a bare artifact selects every
352/// anchor on it. String-typed like [`AnchorInput`] so an unknown grain or
353/// class refuses typed (`INVALID_ANCHOR`) rather than silently selecting
354/// nothing forever. Call [`Self::validate`] to obtain a strict
355/// [`AnchorUnset`].
356#[derive(Debug, Clone, Default, Serialize, Deserialize)]
357pub struct AnchorUnsetInput {
358    #[serde(default)]
359    pub artifact: Option<String>,
360    #[serde(default)]
361    pub grain: Option<String>,
362    #[serde(default)]
363    pub class: Option<String>,
364}
365
366impl AnchorUnsetInput {
367    /// Validate this wire element into a strict [`AnchorUnset`], or refuse
368    /// typed. Rules: artifact present and non-empty; grain / class, when
369    /// supplied, must be known wire strings (absent means "any").
370    pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
371        let artifact = self
372            .artifact
373            .as_deref()
374            .map(str::trim)
375            .filter(|s| !s.is_empty())
376            .map(str::to_string)
377            .ok_or(AnchorValidationError::MissingArtifact)?;
378        let grain = match self.grain.as_deref() {
379            None => None,
380            Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
381                AnchorValidationError::UnknownGrain {
382                    got: Some(s.to_string()),
383                    allowed: AnchorGrain::WIRE_VALUES,
384                }
385            })?),
386        };
387        let class = match self.class.as_deref() {
388            None => None,
389            Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
390                AnchorValidationError::UnknownClass {
391                    got: Some(s.to_string()),
392                    allowed: AnchorProvenanceClass::WIRE_VALUES,
393                }
394            })?),
395        };
396        Ok(AnchorUnset {
397            artifact,
398            grain,
399            class,
400        })
401    }
402}
403
404/// A validated explicit-removal selector: which of an entity's anchors an
405/// update's `anchors_unset[]` entry removes. Selection is by artifact,
406/// optionally narrowed by grain and/or class; a selector matching nothing
407/// is a no-op (removal is idempotent — its job in recovery flows is "make
408/// sure this is gone").
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct AnchorUnset {
411    /// Artifact reference to remove anchors from, exactly as stored.
412    pub artifact: String,
413    /// When present, only anchors of this grain are removed.
414    pub grain: Option<AnchorGrain>,
415    /// When present, only anchors of this class are removed.
416    pub class: Option<AnchorProvenanceClass>,
417}
418
419impl AnchorUnset {
420    /// Whether this selector removes `anchor`.
421    pub fn matches(&self, anchor: &Anchor) -> bool {
422        anchor.artifact == self.artifact
423            && self.grain.is_none_or(|g| anchor.grain == g)
424            && self.class.is_none_or(|c| anchor.class == c)
425    }
426}
427
428/// A typed `INVALID_ANCHOR` refusal. The whole mutation refuses and the
429/// entity is not written; [`Self::detail`] carries the recovery payload
430/// (offending value + allowed set) the agent fixes from.
431#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
432pub enum AnchorValidationError {
433    /// Provenance class is absent or not one of the allowed wire strings.
434    #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
435    UnknownClass {
436        got: Option<String>,
437        allowed: &'static [&'static str],
438    },
439    /// Grain is absent or not one of the allowed wire strings.
440    #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
441    UnknownGrain {
442        got: Option<String>,
443        allowed: &'static [&'static str],
444    },
445    /// Hash stability, when supplied, is not an allowed wire string.
446    #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
447    UnknownHashStability {
448        got: String,
449        allowed: &'static [&'static str],
450    },
451    /// The artifact reference is missing or empty.
452    #[error("anchor is missing its artifact reference")]
453    MissingArtifact,
454    /// A content hash was supplied on a class that carries no hash
455    /// semantics (`authored` / `informed-by`).
456    #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
457    HashOnNonHashClass { class: &'static str },
458    /// A `source` was supplied but is empty after trimming — a source
459    /// name, when present, must be one of the producing binding's
460    /// declared names, and an empty string can never be one.
461    #[error("anchor `source`, when present, must be a non-empty source name")]
462    EmptySource,
463    /// The anchor's `source` is not among the sources declared by its
464    /// own (resolvable) producing binding. Carries the declared names
465    /// as the recovery payload. Only fires when the `binding` hash
466    /// still resolves in this workspace — an orphaned or since-edited
467    /// binding accepts any non-empty name.
468    #[error(
469        "anchor `source` {got:?} is not declared by the anchor's producing binding; declared          sources: {}",
470        declared.join(", ")
471    )]
472    SourceNotDeclared { got: String, declared: Vec<String> },
473    /// The grain cannot be expressed in the medium's anchor namespace
474    /// (per the E2 capability matrix), e.g. `span` on a non-path medium.
475    #[error(
476        "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
477         '{anchor_namespace}' namespace does not admit that grain"
478    )]
479    GrainNamespaceUnsupported {
480        grain: &'static str,
481        medium_type: String,
482        anchor_namespace: &'static str,
483    },
484}
485
486impl AnchorValidationError {
487    /// The stable typed code — always [`INVALID_ANCHOR_CODE`].
488    pub fn code(&self) -> &'static str {
489        INVALID_ANCHOR_CODE
490    }
491
492    /// Structured recovery detail for the typed envelope: the offending
493    /// field, its bad value, and the allowed set where one applies.
494    pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
495        let mut d = BTreeMap::new();
496        match self {
497            AnchorValidationError::UnknownClass { got, allowed } => {
498                d.insert("field".into(), "class".into());
499                d.insert("got".into(), serde_json::json!(got));
500                d.insert("allowed".into(), serde_json::json!(allowed));
501            }
502            AnchorValidationError::UnknownGrain { got, allowed } => {
503                d.insert("field".into(), "grain".into());
504                d.insert("got".into(), serde_json::json!(got));
505                d.insert("allowed".into(), serde_json::json!(allowed));
506            }
507            AnchorValidationError::UnknownHashStability { got, allowed } => {
508                d.insert("field".into(), "hash_stability".into());
509                d.insert("got".into(), serde_json::json!(got));
510                d.insert("allowed".into(), serde_json::json!(allowed));
511            }
512            AnchorValidationError::MissingArtifact => {
513                d.insert("field".into(), "artifact".into());
514            }
515            AnchorValidationError::EmptySource => {
516                d.insert("field".into(), "source".into());
517            }
518            AnchorValidationError::SourceNotDeclared { got, declared } => {
519                d.insert("field".into(), "source".into());
520                d.insert("got".into(), serde_json::json!(got));
521                d.insert("declared".into(), serde_json::json!(declared));
522            }
523            AnchorValidationError::HashOnNonHashClass { class } => {
524                d.insert("field".into(), "hash".into());
525                d.insert("class".into(), serde_json::json!(class));
526            }
527            AnchorValidationError::GrainNamespaceUnsupported {
528                grain,
529                medium_type,
530                anchor_namespace,
531            } => {
532                d.insert("field".into(), "grain".into());
533                d.insert("grain".into(), serde_json::json!(grain));
534                d.insert("medium_type".into(), serde_json::json!(medium_type));
535                d.insert(
536                    "anchor_namespace".into(),
537                    serde_json::json!(anchor_namespace),
538                );
539            }
540        }
541        d
542    }
543}
544
545impl AnchorInput {
546    /// Validate this wire element into a strict [`Anchor`], or refuse
547    /// typed.
548    ///
549    /// `medium` — the resolving medium's `(type_name, anchor_namespace)`
550    /// pair, when the mutation resolved one. When `Some`, the grain is
551    /// checked against the namespace (the capability-matrix refusal);
552    /// when `None` (no medium context — a manually-authored anchor), the
553    /// namespace check is skipped and only the vocabulary + hash-semantics
554    /// rules apply.
555    ///
556    /// Rules enforced (each a typed [`AnchorValidationError`]):
557    /// - class present and known;
558    /// - grain present and known;
559    /// - artifact reference present and non-empty;
560    /// - a hash is supplied only on a hash-bearing class;
561    /// - hash stability, when supplied, is a known wire string (defaults
562    ///   to `stable` when absent);
563    /// - grain supported by the medium's namespace (when `medium` given).
564    pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
565        let class = match self
566            .class
567            .as_deref()
568            .and_then(AnchorProvenanceClass::from_wire)
569        {
570            Some(c) => c,
571            None => {
572                return Err(AnchorValidationError::UnknownClass {
573                    got: self.class.clone(),
574                    allowed: AnchorProvenanceClass::WIRE_VALUES,
575                });
576            }
577        };
578        let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
579            Some(g) => g,
580            None => {
581                return Err(AnchorValidationError::UnknownGrain {
582                    got: self.grain.clone(),
583                    allowed: AnchorGrain::WIRE_VALUES,
584                });
585            }
586        };
587
588        let artifact = self
589            .artifact
590            .as_deref()
591            .map(str::trim)
592            .filter(|s| !s.is_empty())
593            .map(str::to_string)
594            .ok_or(AnchorValidationError::MissingArtifact)?;
595
596        // Hash stability: default `stable` when absent; refuse an unknown
597        // supplied value.
598        let hash_stability = match self.hash_stability.as_deref() {
599            None => AnchorHashStability::Stable,
600            Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
601                AnchorValidationError::UnknownHashStability {
602                    got: s.to_string(),
603                    allowed: AnchorHashStability::WIRE_VALUES,
604                }
605            })?,
606        };
607
608        // A hash is only meaningful on a hash-bearing class.
609        let hash = self
610            .hash
611            .as_deref()
612            .map(str::trim)
613            .filter(|s| !s.is_empty())
614            .map(str::to_string);
615        if hash.is_some() && !class.is_hash_bearing() {
616            return Err(AnchorValidationError::HashOnNonHashClass {
617                class: class.as_wire(),
618            });
619        }
620
621        // Grain must be expressible in the medium's namespace.
622        if let Some((medium_type, namespace)) = medium
623            && !grain.supported_by_namespace(namespace)
624        {
625            // Resolve the namespace to its `&'static str` so the error
626            // carries a stable value even though the input came borrowed.
627            let anchor_namespace = match namespace {
628                "path" => "path",
629                "path+commit" => "path+commit",
630                "entity" => "entity",
631                "url" => "url",
632                _ => "path",
633            };
634            return Err(AnchorValidationError::GrainNamespaceUnsupported {
635                grain: grain.as_wire(),
636                medium_type: medium_type.to_string(),
637                anchor_namespace,
638            });
639        }
640
641        // `source`, when present, must be non-empty. (Whether it names a
642        // source the producing binding actually declares is checked at
643        // the engine seam, which can resolve the binding hash — this
644        // context-free validator cannot.)
645        let source = match self.source.as_deref() {
646            None => None,
647            Some(raw) => {
648                let trimmed = raw.trim();
649                if trimmed.is_empty() {
650                    return Err(AnchorValidationError::EmptySource);
651                }
652                Some(trimmed.to_string())
653            }
654        };
655
656        Ok(Anchor {
657            artifact,
658            grain,
659            class,
660            at_version: self.at_version.clone(),
661            hash,
662            hash_stability,
663            derived_from: self.derived_from.clone().unwrap_or_default(),
664            binding: self
665                .binding
666                .as_deref()
667                .map(str::trim)
668                .filter(|s| !s.is_empty())
669                .map(str::to_string),
670            source,
671        })
672    }
673}
674
675// ---------------------------------------------------------------------------
676// Prepared-content hash
677// ---------------------------------------------------------------------------
678
679/// Compute the **prepared-content hash** of a path-grain artifact's bytes —
680/// the value [`Anchor::hash`] records and hash-drift adjudication compares.
681///
682/// The prepared form is a deliberate, minimal canonicalization that keeps the
683/// hash stable across meaningless byte noise while preserving every
684/// content-bearing byte. For UTF-8 text:
685///
686/// - a leading BOM (U+FEFF) is stripped;
687/// - CRLF / lone-CR line endings normalize to LF;
688/// - trailing newlines are trimmed (final-newline presence is noise).
689///
690/// Interior whitespace is untouched — trailing spaces inside a line can be
691/// content (markdown hard breaks), so only the two classic cross-tool noise
692/// sources (encoding marks, line-ending convention) and the final-newline
693/// question are canonicalized. Non-UTF-8 (binary) bytes hash as-is — no text
694/// canonicalization applies to them.
695///
696/// The hash form reuses the house convention — SHA-256, lowercase hex,
697/// truncated to 16 characters — shared by entity content hashes
698/// ([`crate::entity::parser::compute_hash`]) and the change-detection digest
699/// aggregate, so the engine keeps one hash shape rather than growing a
700/// second normalization.
701pub fn prepared_content_hash(bytes: &[u8]) -> String {
702    use sha2::{Digest as _, Sha256};
703    let digest = match std::str::from_utf8(bytes) {
704        Ok(text) => {
705            let text = text.strip_prefix('\u{feff}').unwrap_or(text);
706            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
707            Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
708        }
709        Err(_) => Sha256::digest(bytes),
710    };
711    crate::hex_lower(&digest)[..16].to_string()
712}
713
714/// One verify-observed prepared-content hash, addressed to the anchor(s) it
715/// backfills: the `(entity, artifact)` pair a hash-less hash-bearing anchor
716/// is keyed by in the sidecar, plus the hash the observation computed. The
717/// verify pass collects these; the engine's sidecar writer records them.
718#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719pub struct ObservedArtifactHash {
720    /// The entity id (`mem--slug`) whose anchor the hash belongs to.
721    pub entity: String,
722    /// The anchor's artifact reference, exactly as stored.
723    pub artifact: String,
724    /// The prepared-content hash observed for the artifact.
725    pub hash: String,
726}
727
728// ---------------------------------------------------------------------------
729// Resolution
730// ---------------------------------------------------------------------------
731
732/// The resolved state of one anchor against the current medium.
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
734#[serde(rename_all = "lowercase")]
735pub enum AnchorState {
736    /// The artifact is present and matches (hash equal, or a non-hash
737    /// class whose artifact still exists).
738    Resolves,
739    /// The artifact is present but its prepared-content hash differs and
740    /// the medium is `stable` — a real content drift.
741    Drifted,
742    /// The artifact is present but drift cannot be asserted — the medium
743    /// is `unstable`, or the hash is unavailable on one side. Flagged for
744    /// re-examination, never reported as drift.
745    Recheck,
746    /// The artifact the anchor references is no longer present in the
747    /// medium.
748    Orphaned,
749}
750
751impl AnchorState {
752    /// Stable wire form.
753    pub fn as_wire(&self) -> &'static str {
754        match self {
755            AnchorState::Resolves => "resolves",
756            AnchorState::Drifted => "drifted",
757            AnchorState::Recheck => "recheck",
758            AnchorState::Orphaned => "orphaned",
759        }
760    }
761}
762
763/// What the engine observed about an anchor's artifact when resolving.
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub enum ArtifactObservation {
766    /// The artifact could not be found in the medium.
767    Absent,
768    /// The artifact is present; `current_hash` is its prepared-content
769    /// hash when the medium could compute one (`None` when the medium has
770    /// no hash for it this pass — e.g. enumeration without preparation).
771    Present { current_hash: Option<String> },
772}
773
774/// Resolve one anchor against a current observation, honouring the class's
775/// hash semantics and the medium's declared stability.
776///
777/// - `authored` / `informed-by` are excluded from hash-drift adjudication:
778///   they [`Resolves`](AnchorState::Resolves) as long as the artifact
779///   exists, [`Orphaned`](AnchorState::Orphaned) when it does not — a
780///   content change never produces a drift state for them.
781/// - `anchored` / `derived` compare the recorded prepared-content hash to
782///   the current one: equal ⇒ resolves; different ⇒ `drifted` on a stable
783///   medium, `recheck` on an unstable one; unavailable on either side ⇒
784///   `recheck` (cannot adjudicate).
785pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
786    let current_hash = match observation {
787        ArtifactObservation::Absent => return AnchorState::Orphaned,
788        ArtifactObservation::Present { current_hash } => current_hash,
789    };
790    if !anchor.class.is_hash_bearing() {
791        return AnchorState::Resolves;
792    }
793    match (&anchor.hash, current_hash) {
794        (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
795        (Some(_), Some(_)) => match anchor.hash_stability {
796            AnchorHashStability::Stable => AnchorState::Drifted,
797            AnchorHashStability::Unstable => AnchorState::Recheck,
798        },
799        // Missing hash on either side — cannot adjudicate drift.
800        _ => AnchorState::Recheck,
801    }
802}
803
804/// Per-entity provenance-class + grain composition, computed from an
805/// entity's anchor list. Tree-grain fan-out is surfaced distinctly so a
806/// single entity anchored to a large tree is never laundered into
807/// full per-file credit — the count of tree anchors is visible on its own
808/// axis, and downstream (E3b) reads the fan-out counts from resolution.
809#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
810pub struct EntityAnchorComposition {
811    /// Anchor count keyed by provenance-class wire string.
812    pub by_class: BTreeMap<String, usize>,
813    /// Anchor count keyed by grain wire string.
814    pub by_grain: BTreeMap<String, usize>,
815    /// The `derived_from` input lists of every `derived` anchor, in
816    /// anchor order — E3b's derived-input provenance.
817    pub derived_inputs: Vec<Vec<String>>,
818    /// Artifact refs of every `tree`-grain anchor — the fan-out axis. A
819    /// tree anchor is one row here regardless of how many files the tree
820    /// contains; the file count is an observation resolution supplies, not
821    /// a credit this composition grants.
822    pub tree_grain_artifacts: Vec<String>,
823}
824
825/// Compose an entity's anchors into class/grain counts, derived inputs,
826/// and the tree-grain fan-out axis.
827pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
828    let mut comp = EntityAnchorComposition::default();
829    for a in anchors {
830        *comp
831            .by_class
832            .entry(a.class.as_wire().to_string())
833            .or_insert(0) += 1;
834        *comp
835            .by_grain
836            .entry(a.grain.as_wire().to_string())
837            .or_insert(0) += 1;
838        if a.class == AnchorProvenanceClass::Derived {
839            comp.derived_inputs.push(a.derived_from.clone());
840        }
841        if a.grain == AnchorGrain::Tree {
842            comp.tree_grain_artifacts.push(a.artifact.clone());
843        }
844    }
845    comp
846}
847
848// ---------------------------------------------------------------------------
849// Sidecar document
850// ---------------------------------------------------------------------------
851
852/// The engine-owned anchors sidecar document persisted at
853/// [`ANCHOR_SIDECAR_PATH`] on the mem branch: entity id → its anchors.
854///
855/// Written only through engine commits (the [`crate::backend::MemBackend`]
856/// sidecar seam). Rename rewrites the key atomically in the same commit as
857/// the entity move; delete drops the key in the same commit as the entity
858/// delete.
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860pub struct AnchorSidecar {
861    /// Document schema version.
862    pub version: u32,
863    /// Entity id (`mem--slug`) → its anchors. An entity with no anchors
864    /// carries no key (an empty vec is pruned on write).
865    #[serde(default)]
866    pub entities: BTreeMap<String, Vec<Anchor>>,
867}
868
869impl Default for AnchorSidecar {
870    fn default() -> Self {
871        Self {
872            version: ANCHOR_SIDECAR_VERSION,
873            entities: BTreeMap::new(),
874        }
875    }
876}
877
878impl AnchorSidecar {
879    /// Parse sidecar bytes; an absent/empty payload yields an empty
880    /// document so callers need not special-case a fresh mem.
881    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
882        if bytes.iter().all(u8::is_ascii_whitespace) {
883            return Ok(Self::default());
884        }
885        serde_json::from_slice(bytes)
886    }
887
888    /// Serialise to canonical pretty JSON with a trailing newline —
889    /// diff-friendly on the mem branch.
890    pub fn to_bytes(&self) -> Vec<u8> {
891        let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
892        s.push('\n');
893        s.into_bytes()
894    }
895
896    /// The anchors recorded for `entity_id`, or an empty slice.
897    pub fn get(&self, entity_id: &str) -> &[Anchor] {
898        self.entities
899            .get(entity_id)
900            .map(Vec::as_slice)
901            .unwrap_or(&[])
902    }
903
904    /// Replace `entity_id`'s anchors. An empty list prunes the key so the
905    /// sidecar never accumulates empty rows.
906    pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
907        if anchors.is_empty() {
908            self.entities.remove(entity_id);
909        } else {
910            self.entities.insert(entity_id.to_string(), anchors);
911        }
912    }
913
914    /// Merge `incoming` into `entity_id`'s anchor row after applying
915    /// `unsets` — the write-path set arithmetic.
916    ///
917    /// Unset applies **first**: each selector removes its matching anchors
918    /// (a selector matching nothing is a no-op). Then each incoming anchor
919    /// **replaces** the surviving anchor with the same
920    /// `(artifact, grain, class)` triple in place, and **appends**
921    /// otherwise — untouched anchors keep their bytes and their position.
922    /// Writing anchors never removes an anchor the call did not name in
923    /// `unsets`; an empty `incoming` merges nothing. A row emptied by
924    /// unsets prunes its key so the sidecar never accumulates empty rows.
925    pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
926        let mut row = self.entities.remove(entity_id).unwrap_or_default();
927        row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
928        for anchor in incoming {
929            match row.iter_mut().find(|e| {
930                e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
931            }) {
932                Some(existing) => *existing = anchor,
933                None => row.push(anchor),
934            }
935        }
936        if !row.is_empty() {
937            self.entities.insert(entity_id.to_string(), row);
938        }
939    }
940
941    /// Drop `entity_id`'s anchors entirely (delete leg). Idempotent.
942    pub fn remove(&mut self, entity_id: &str) {
943        self.entities.remove(entity_id);
944    }
945
946    /// Move `from`'s anchors to `to` (rename leg), leaving zero rows under
947    /// the old id. No-op when `from` has no anchors. When `to` already has
948    /// anchors they are overwritten — a rename onto a live id is refused
949    /// upstream, so this is the residual-stub case only.
950    pub fn rename(&mut self, from: &str, to: &str) {
951        if let Some(anchors) = self.entities.remove(from) {
952            self.entities.insert(to.to_string(), anchors);
953        }
954    }
955
956    /// Whether the document holds no anchors for any entity.
957    pub fn is_empty(&self) -> bool {
958        self.entities.is_empty()
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    // -- wire vocabulary is the contract -----------------------------------
967
968    #[test]
969    fn class_wire_strings_are_stable() {
970        assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
971        assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
972        assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
973        assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
974        for w in AnchorProvenanceClass::WIRE_VALUES {
975            assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
976        }
977        assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
978    }
979
980    #[test]
981    fn grain_wire_strings_are_stable() {
982        for w in AnchorGrain::WIRE_VALUES {
983            assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
984        }
985        assert_eq!(
986            AnchorGrain::WIRE_VALUES,
987            &["span", "file", "tree", "url", "entity"]
988        );
989        assert!(AnchorGrain::from_wire("chunk").is_none());
990    }
991
992    #[test]
993    fn stability_and_state_wire_strings_are_stable() {
994        assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
995        assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
996        assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
997        assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
998        assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
999        assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1000    }
1001
1002    #[test]
1003    fn only_anchored_and_derived_are_hash_bearing() {
1004        assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1005        assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1006        assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1007        assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1008    }
1009
1010    // -- grain / namespace matrix ------------------------------------------
1011
1012    #[test]
1013    fn grain_namespace_support_matches_capability_matrix() {
1014        // path-shaped grains need path / path+commit.
1015        for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1016            assert!(g.supported_by_namespace("path"));
1017            assert!(g.supported_by_namespace("path+commit"));
1018            assert!(!g.supported_by_namespace("url"));
1019            assert!(!g.supported_by_namespace("entity"));
1020        }
1021        assert!(AnchorGrain::Url.supported_by_namespace("url"));
1022        assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1023        assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1024        assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1025    }
1026
1027    // -- validation refusals -----------------------------------------------
1028
1029    fn valid_input() -> AnchorInput {
1030        AnchorInput {
1031            artifact: Some("src/lib.rs".into()),
1032            grain: Some("file".into()),
1033            class: Some("anchored".into()),
1034            hash_stability: Some("stable".into()),
1035            hash: Some("abc123".into()),
1036            ..Default::default()
1037        }
1038    }
1039
1040    #[test]
1041    fn validate_accepts_a_well_formed_anchor() {
1042        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1043        assert_eq!(a.artifact, "src/lib.rs");
1044        assert_eq!(a.grain, AnchorGrain::File);
1045        assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1046        assert_eq!(a.hash.as_deref(), Some("abc123"));
1047        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1048    }
1049
1050    #[test]
1051    fn validate_defaults_hash_stability_to_stable() {
1052        let mut i = valid_input();
1053        i.hash_stability = None;
1054        let a = i.validate(None).unwrap();
1055        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1056    }
1057
1058    #[test]
1059    fn validate_refuses_unknown_class() {
1060        let mut i = valid_input();
1061        i.class = Some("guessed".into());
1062        let err = i.validate(None).unwrap_err();
1063        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1064        assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1065        assert_eq!(err.detail()["field"], serde_json::json!("class"));
1066    }
1067
1068    #[test]
1069    fn validate_refuses_unknown_grain() {
1070        let mut i = valid_input();
1071        i.grain = Some("paragraph".into());
1072        let err = i.validate(None).unwrap_err();
1073        assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1074    }
1075
1076    #[test]
1077    fn validate_refuses_missing_artifact() {
1078        let mut i = valid_input();
1079        i.artifact = Some("   ".into());
1080        let err = i.validate(None).unwrap_err();
1081        assert!(matches!(err, AnchorValidationError::MissingArtifact));
1082        i.artifact = None;
1083        assert!(matches!(
1084            valid_input_with_artifact(None).validate(None).unwrap_err(),
1085            AnchorValidationError::MissingArtifact
1086        ));
1087        let _ = i;
1088    }
1089
1090    fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1091        AnchorInput {
1092            artifact: a,
1093            ..valid_input()
1094        }
1095    }
1096
1097    #[test]
1098    fn validate_refuses_hash_on_non_hash_class() {
1099        let mut i = valid_input();
1100        i.class = Some("authored".into());
1101        // hash still supplied → refuse
1102        let err = i.validate(None).unwrap_err();
1103        assert!(matches!(
1104            err,
1105            AnchorValidationError::HashOnNonHashClass { class: "authored" }
1106        ));
1107    }
1108
1109    #[test]
1110    fn validate_accepts_non_hash_class_without_hash() {
1111        let mut i = valid_input();
1112        i.class = Some("informed-by".into());
1113        i.hash = None;
1114        let a = i.validate(None).unwrap();
1115        assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1116        assert!(a.hash.is_none());
1117    }
1118
1119    #[test]
1120    fn validate_refuses_grain_unsupported_by_medium_namespace() {
1121        // span grain on a web (url namespace) medium.
1122        let mut i = valid_input();
1123        i.grain = Some("span".into());
1124        i.class = Some("authored".into());
1125        i.hash = None;
1126        let err = i.validate(Some(("web", "url"))).unwrap_err();
1127        match err {
1128            AnchorValidationError::GrainNamespaceUnsupported {
1129                grain,
1130                anchor_namespace,
1131                ..
1132            } => {
1133                assert_eq!(grain, "span");
1134                assert_eq!(anchor_namespace, "url");
1135            }
1136            other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1137        }
1138    }
1139
1140    #[test]
1141    fn validate_skips_namespace_check_without_medium_context() {
1142        // span grain, no medium → namespace rule not applied.
1143        let mut i = valid_input();
1144        i.grain = Some("span".into());
1145        assert!(i.validate(None).is_ok());
1146    }
1147
1148    // -- prepared-content hash ----------------------------------------------
1149
1150    /// The prepared form is stable across meaningless byte noise: BOM,
1151    /// line-ending convention, and final-newline presence never move the
1152    /// hash — a real content change always does.
1153    #[test]
1154    fn prepared_hash_is_stable_across_byte_noise() {
1155        let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1156        // CRLF and lone-CR line endings normalize away.
1157        assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1158        assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1159        // Final-newline presence (missing, single, several) is noise.
1160        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1161        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1162        // A leading UTF-8 BOM is stripped.
1163        assert_eq!(
1164            prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1165            base
1166        );
1167        // A real content change moves the hash.
1168        assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1169        // House hash shape: 16 lowercase hex chars.
1170        assert_eq!(base.len(), 16);
1171        assert!(
1172            base.chars()
1173                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1174        );
1175    }
1176
1177    /// Interior whitespace is content, not noise: a trailing space inside a
1178    /// line (markdown hard break) changes the hash.
1179    #[test]
1180    fn prepared_hash_preserves_interior_whitespace() {
1181        assert_ne!(
1182            prepared_content_hash(b"line one  \nline two\n"),
1183            prepared_content_hash(b"line one\nline two\n")
1184        );
1185    }
1186
1187    /// Non-UTF-8 bytes hash raw — no text canonicalization is applied, and
1188    /// any byte change moves the hash.
1189    #[test]
1190    fn prepared_hash_hashes_binary_bytes_raw() {
1191        let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1192        let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1193        assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1194        // Deterministic.
1195        assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1196    }
1197
1198    // -- resolution --------------------------------------------------------
1199
1200    fn anchor(
1201        class: AnchorProvenanceClass,
1202        hash: Option<&str>,
1203        stab: AnchorHashStability,
1204    ) -> Anchor {
1205        Anchor {
1206            artifact: "src/lib.rs".into(),
1207            grain: AnchorGrain::File,
1208            class,
1209            at_version: None,
1210            hash: hash.map(str::to_string),
1211            hash_stability: stab,
1212            derived_from: Vec::new(),
1213            binding: None,
1214            source: None,
1215        }
1216    }
1217
1218    #[test]
1219    fn resolves_when_hash_matches() {
1220        let a = anchor(
1221            AnchorProvenanceClass::Anchored,
1222            Some("h1"),
1223            AnchorHashStability::Stable,
1224        );
1225        let obs = ArtifactObservation::Present {
1226            current_hash: Some("h1".into()),
1227        };
1228        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1229    }
1230
1231    #[test]
1232    fn stable_hash_break_drifts_unstable_rechecks() {
1233        let stable = anchor(
1234            AnchorProvenanceClass::Anchored,
1235            Some("h1"),
1236            AnchorHashStability::Stable,
1237        );
1238        let unstable = anchor(
1239            AnchorProvenanceClass::Anchored,
1240            Some("h1"),
1241            AnchorHashStability::Unstable,
1242        );
1243        let obs = ArtifactObservation::Present {
1244            current_hash: Some("h2".into()),
1245        };
1246        assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
1247        assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
1248    }
1249
1250    #[test]
1251    fn absent_artifact_is_orphaned() {
1252        let a = anchor(
1253            AnchorProvenanceClass::Anchored,
1254            Some("h1"),
1255            AnchorHashStability::Stable,
1256        );
1257        assert_eq!(
1258            resolve_anchor(&a, &ArtifactObservation::Absent),
1259            AnchorState::Orphaned
1260        );
1261    }
1262
1263    #[test]
1264    fn non_hash_classes_never_drift() {
1265        for class in [
1266            AnchorProvenanceClass::Authored,
1267            AnchorProvenanceClass::InformedBy,
1268        ] {
1269            let a = anchor(class, None, AnchorHashStability::Stable);
1270            // Content moved underneath — still resolves (excluded from
1271            // hash-drift adjudication).
1272            let obs = ArtifactObservation::Present {
1273                current_hash: Some("whatever".into()),
1274            };
1275            assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1276            // But an absent artifact is still orphaned.
1277            assert_eq!(
1278                resolve_anchor(&a, &ArtifactObservation::Absent),
1279                AnchorState::Orphaned
1280            );
1281        }
1282    }
1283
1284    #[test]
1285    fn unavailable_hash_rechecks_not_drifts() {
1286        let a = anchor(
1287            AnchorProvenanceClass::Anchored,
1288            Some("h1"),
1289            AnchorHashStability::Stable,
1290        );
1291        let obs = ArtifactObservation::Present { current_hash: None };
1292        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1293    }
1294
1295    // -- composition -------------------------------------------------------
1296
1297    #[test]
1298    fn composition_counts_classes_grains_and_tree_fanout() {
1299        let anchors = vec![
1300            Anchor {
1301                artifact: "a.rs".into(),
1302                grain: AnchorGrain::File,
1303                class: AnchorProvenanceClass::Anchored,
1304                at_version: None,
1305                hash: Some("h".into()),
1306                hash_stability: AnchorHashStability::Stable,
1307                derived_from: Vec::new(),
1308                binding: None,
1309                source: None,
1310            },
1311            Anchor {
1312                artifact: "src/".into(),
1313                grain: AnchorGrain::Tree,
1314                class: AnchorProvenanceClass::Derived,
1315                at_version: None,
1316                hash: Some("t".into()),
1317                hash_stability: AnchorHashStability::Stable,
1318                derived_from: vec!["a.rs".into(), "b.rs".into()],
1319                binding: None,
1320                source: None,
1321            },
1322        ];
1323        let comp = compose_entity_anchors(&anchors);
1324        assert_eq!(comp.by_class["anchored"], 1);
1325        assert_eq!(comp.by_class["derived"], 1);
1326        assert_eq!(comp.by_grain["file"], 1);
1327        assert_eq!(comp.by_grain["tree"], 1);
1328        // Tree fan-out is a distinct axis — one row, never per-file credit.
1329        assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1330        assert_eq!(
1331            comp.derived_inputs,
1332            vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1333        );
1334    }
1335
1336    // -- sidecar round-trip -------------------------------------------------
1337
1338    #[test]
1339    fn sidecar_round_trips_and_prunes_empty() {
1340        let mut sc = AnchorSidecar::default();
1341        assert!(sc.is_empty());
1342        let a = anchor(
1343            AnchorProvenanceClass::Anchored,
1344            Some("h1"),
1345            AnchorHashStability::Stable,
1346        );
1347        sc.set("specs--x", vec![a.clone()]);
1348        assert_eq!(sc.get("specs--x").len(), 1);
1349
1350        let bytes = sc.to_bytes();
1351        let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1352        assert_eq!(round, sc);
1353
1354        // Setting empty prunes the key.
1355        sc.set("specs--x", vec![]);
1356        assert!(sc.is_empty());
1357        assert!(sc.get("specs--x").is_empty());
1358    }
1359
1360    // -- merge / unset arithmetic ------------------------------------------
1361
1362    fn file_anchor(artifact: &str, hash: &str) -> Anchor {
1363        Anchor {
1364            artifact: artifact.into(),
1365            grain: AnchorGrain::File,
1366            class: AnchorProvenanceClass::Anchored,
1367            at_version: None,
1368            hash: Some(hash.into()),
1369            hash_stability: AnchorHashStability::Stable,
1370            derived_from: Vec::new(),
1371            binding: None,
1372            source: None,
1373        }
1374    }
1375
1376    /// Merge appends a new triple and leaves the existing set untouched —
1377    /// the incremental-anchoring contract (N existing + 1 new ⇒ N+1).
1378    #[test]
1379    fn merge_appends_new_triple_without_touching_others() {
1380        let mut sc = AnchorSidecar::default();
1381        sc.set(
1382            "m--e",
1383            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1384        );
1385        sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
1386        let row = sc.get("m--e");
1387        assert_eq!(row.len(), 3);
1388        assert_eq!(row[0], file_anchor("a.rs", "h-a"));
1389        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1390        assert_eq!(row[2], file_anchor("c.rs", "h-c"));
1391    }
1392
1393    /// An incoming anchor with an existing `(artifact, grain, class)`
1394    /// triple replaces exactly that one, in place; others stay
1395    /// byte-identical.
1396    #[test]
1397    fn merge_replaces_same_triple_in_place() {
1398        let mut sc = AnchorSidecar::default();
1399        sc.set(
1400            "m--e",
1401            vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
1402        );
1403        sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
1404        let row = sc.get("m--e");
1405        assert_eq!(row.len(), 2);
1406        assert_eq!(row[0], file_anchor("a.rs", "h-new"));
1407        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
1408    }
1409
1410    /// Same artifact under a different grain or class is a different
1411    /// identity — it appends rather than replaces (the triple is the merge
1412    /// key, not the artifact alone).
1413    #[test]
1414    fn merge_treats_grain_and_class_as_identity() {
1415        let mut sc = AnchorSidecar::default();
1416        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1417        let mut span = file_anchor("a.rs", "h-span");
1418        span.grain = AnchorGrain::Span;
1419        let mut informed = file_anchor("a.rs", "h-a");
1420        informed.class = AnchorProvenanceClass::InformedBy;
1421        informed.hash = None;
1422        sc.merge("m--e", &[], vec![span, informed]);
1423        assert_eq!(sc.get("m--e").len(), 3);
1424    }
1425
1426    /// Re-sending an entity's full current set is a no-op on the stored
1427    /// bytes, and merging an empty list changes nothing.
1428    #[test]
1429    fn merge_full_resend_and_empty_are_noops() {
1430        let mut sc = AnchorSidecar::default();
1431        sc.set(
1432            "m--e",
1433            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1434        );
1435        let before = sc.to_bytes();
1436        sc.merge(
1437            "m--e",
1438            &[],
1439            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
1440        );
1441        assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
1442        sc.merge("m--e", &[], Vec::new());
1443        assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
1444    }
1445
1446    /// A bare-artifact unset removes all of that artifact's anchors and
1447    /// nothing else; a grain/class-narrowed unset removes only the match;
1448    /// a selector matching nothing is a no-op.
1449    #[test]
1450    fn unset_selects_by_artifact_with_optional_narrowing() {
1451        let mut span = file_anchor("a.rs", "h-span");
1452        span.grain = AnchorGrain::Span;
1453        let mut sc = AnchorSidecar::default();
1454        sc.set(
1455            "m--e",
1456            vec![
1457                file_anchor("a.rs", "h-a"),
1458                span.clone(),
1459                file_anchor("b.rs", "h-b"),
1460            ],
1461        );
1462
1463        // Narrowed: only the span-grain anchor on a.rs goes.
1464        let narrowed = AnchorUnset {
1465            artifact: "a.rs".into(),
1466            grain: Some(AnchorGrain::Span),
1467            class: None,
1468        };
1469        sc.merge("m--e", &[narrowed], Vec::new());
1470        assert_eq!(
1471            sc.get("m--e"),
1472            &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
1473        );
1474
1475        // Nonexistent target: idempotent no-op.
1476        let missing = AnchorUnset {
1477            artifact: "never-there.rs".into(),
1478            grain: None,
1479            class: None,
1480        };
1481        sc.merge("m--e", &[missing], Vec::new());
1482        assert_eq!(sc.get("m--e").len(), 2);
1483
1484        // Bare artifact: everything on a.rs goes, b.rs untouched.
1485        let bare = AnchorUnset {
1486            artifact: "a.rs".into(),
1487            grain: None,
1488            class: None,
1489        };
1490        sc.merge("m--e", &[bare], Vec::new());
1491        assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
1492    }
1493
1494    /// Unset applies before merge in the same call: unsetting an artifact
1495    /// and writing a new anchor on it lands the new anchor (full-replace
1496    /// stays expressible in one call).
1497    #[test]
1498    fn unset_applies_before_merge() {
1499        let mut span = file_anchor("a.rs", "h-span");
1500        span.grain = AnchorGrain::Span;
1501        let mut sc = AnchorSidecar::default();
1502        sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
1503        let bare = AnchorUnset {
1504            artifact: "a.rs".into(),
1505            grain: None,
1506            class: None,
1507        };
1508        sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
1509        assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
1510    }
1511
1512    /// A row emptied by unsets prunes its key — the sidecar never keeps
1513    /// empty rows.
1514    #[test]
1515    fn merge_prunes_row_emptied_by_unset() {
1516        let mut sc = AnchorSidecar::default();
1517        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
1518        let bare = AnchorUnset {
1519            artifact: "a.rs".into(),
1520            grain: None,
1521            class: None,
1522        };
1523        sc.merge("m--e", &[bare], Vec::new());
1524        assert!(sc.is_empty());
1525        assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
1526    }
1527
1528    /// The unset validator: artifact required; grain/class, when supplied,
1529    /// must be known wire strings; absent narrowing means "any".
1530    #[test]
1531    fn unset_input_validates_typed() {
1532        let ok = AnchorUnsetInput {
1533            artifact: Some("  a.rs  ".into()),
1534            grain: Some("span".into()),
1535            class: None,
1536        }
1537        .validate()
1538        .unwrap();
1539        assert_eq!(ok.artifact, "a.rs");
1540        assert_eq!(ok.grain, Some(AnchorGrain::Span));
1541        assert_eq!(ok.class, None);
1542
1543        let missing = AnchorUnsetInput::default().validate().unwrap_err();
1544        assert!(matches!(missing, AnchorValidationError::MissingArtifact));
1545        assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
1546
1547        let bad_grain = AnchorUnsetInput {
1548            artifact: Some("a.rs".into()),
1549            grain: Some("paragraph".into()),
1550            class: None,
1551        }
1552        .validate()
1553        .unwrap_err();
1554        assert!(matches!(
1555            bad_grain,
1556            AnchorValidationError::UnknownGrain { .. }
1557        ));
1558
1559        let bad_class = AnchorUnsetInput {
1560            artifact: Some("a.rs".into()),
1561            grain: None,
1562            class: Some("guessed".into()),
1563        }
1564        .validate()
1565        .unwrap_err();
1566        assert!(matches!(
1567            bad_class,
1568            AnchorValidationError::UnknownClass { .. }
1569        ));
1570    }
1571
1572    #[test]
1573    fn sidecar_rename_leaves_zero_rows_under_old_id() {
1574        let mut sc = AnchorSidecar::default();
1575        sc.set(
1576            "specs--old",
1577            vec![anchor(
1578                AnchorProvenanceClass::Anchored,
1579                Some("h"),
1580                AnchorHashStability::Stable,
1581            )],
1582        );
1583        sc.rename("specs--old", "specs--new");
1584        assert!(sc.get("specs--old").is_empty());
1585        assert_eq!(sc.get("specs--new").len(), 1);
1586    }
1587
1588    #[test]
1589    fn sidecar_remove_drops_entity_anchors() {
1590        let mut sc = AnchorSidecar::default();
1591        sc.set(
1592            "specs--gone",
1593            vec![anchor(
1594                AnchorProvenanceClass::Anchored,
1595                Some("h"),
1596                AnchorHashStability::Stable,
1597            )],
1598        );
1599        sc.remove("specs--gone");
1600        assert!(sc.get("specs--gone").is_empty());
1601        // Idempotent.
1602        sc.remove("specs--gone");
1603    }
1604
1605    #[test]
1606    fn empty_bytes_parse_as_empty_sidecar() {
1607        assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1608        assert!(AnchorSidecar::from_bytes(b"  \n ").unwrap().is_empty());
1609    }
1610
1611    #[test]
1612    fn anchor_json_shape_omits_empty_optionals() {
1613        let a = anchor(
1614            AnchorProvenanceClass::Anchored,
1615            Some("h1"),
1616            AnchorHashStability::Stable,
1617        );
1618        let v = serde_json::to_value(&a).unwrap();
1619        assert_eq!(v["artifact"], "src/lib.rs");
1620        assert_eq!(v["grain"], "file");
1621        assert_eq!(v["class"], "anchored");
1622        assert_eq!(v["hash"], "h1");
1623        assert_eq!(v["hash_stability"], "stable");
1624        // Absent optionals are skipped, not null.
1625        assert!(v.get("at_version").is_none());
1626        assert!(v.get("derived_from").is_none());
1627        assert!(v.get("binding").is_none());
1628    }
1629
1630    #[test]
1631    fn anchor_version_serialises_tagged() {
1632        let a = Anchor {
1633            at_version: Some(AnchorVersion::Commit("deadbeef".into())),
1634            ..anchor(
1635                AnchorProvenanceClass::Anchored,
1636                Some("h"),
1637                AnchorHashStability::Stable,
1638            )
1639        };
1640        let v = serde_json::to_value(&a).unwrap();
1641        assert_eq!(v["at_version"]["kind"], "commit");
1642        assert_eq!(v["at_version"]["value"], "deadbeef");
1643    }
1644
1645    /// `source` rides validation: a non-empty name is carried, absent
1646    /// stays absent, and present-but-empty refuses `INVALID_ANCHOR`
1647    /// with `field: source` in the recovery detail.
1648    #[test]
1649    fn validate_source_carried_absent_or_refused_when_empty() {
1650        let mut input = AnchorInput {
1651            artifact: Some("src/lib.rs".into()),
1652            grain: Some("file".into()),
1653            class: Some("anchored".into()),
1654            ..Default::default()
1655        };
1656        assert_eq!(
1657            input.validate(None).unwrap().source,
1658            None,
1659            "absent stays absent"
1660        );
1661
1662        input.source = Some("  api-docs  ".into());
1663        assert_eq!(
1664            input.validate(None).unwrap().source.as_deref(),
1665            Some("api-docs"),
1666            "non-empty name is carried (trimmed)"
1667        );
1668
1669        input.source = Some("   ".into());
1670        let err = input.validate(None).unwrap_err();
1671        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1672        assert!(matches!(err, AnchorValidationError::EmptySource));
1673        assert_eq!(
1674            err.detail().get("field"),
1675            Some(&serde_json::json!("source"))
1676        );
1677    }
1678
1679    /// A sidecar written before the `source` field existed loads
1680    /// unchanged (additive, optional — no migration, no version bump),
1681    /// and a sourced anchor round-trips through serde.
1682    #[test]
1683    fn source_is_additive_on_the_persisted_shape() {
1684        let pre_plan = r#"{
1685            "artifact": "src/lib.rs",
1686            "grain": "file",
1687            "class": "anchored",
1688            "hash_stability": "stable"
1689        }"#;
1690        let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
1691        assert_eq!(a.source, None, "no backfill, no default");
1692
1693        let sourced = Anchor {
1694            source: Some("api-docs".into()),
1695            ..a
1696        };
1697        let json = serde_json::to_string(&sourced).unwrap();
1698        let back: Anchor = serde_json::from_str(&json).unwrap();
1699        assert_eq!(back.source.as_deref(), Some("api-docs"));
1700    }
1701}