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