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    /// A `span`-grain row whose locator could NOT be checked against the
326    /// artifact at write time (consistency-sweep 03/03). The write path
327    /// deliberately reads no source content, so the check is possible only
328    /// where the caller supplied `content`; elsewhere the anchor is accepted
329    /// and this records that its span is unverified, rather than letting a
330    /// later surface report it as adjudicated. Never set on a non-`span`
331    /// grain. Serialized only when true, so an existing sidecar is unchanged.
332    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
333    pub span_unvalidated: bool,
334    /// Who established this row's [`Self::hash`] baseline. `None` on every
335    /// row written before the field existed, which is honest: the baseline's
336    /// origin was not recorded then and guessing it would be worse than
337    /// admitting it. Set at write and at backfill from then on, so a reader
338    /// can tell an author-pinned baseline from an engine-inferred one.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub hash_source: Option<AnchorHashSource>,
341}
342
343/// Who established an anchor's hash baseline (consistency-sweep 03/03,
344/// criterion 8). A baseline that resets with no trace makes drift
345/// unfalsifiable, so the origin is recorded rather than inferred.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "kebab-case")]
348pub enum AnchorHashSource {
349    /// The writer supplied the hash, or the content the engine hashed.
350    Author,
351    /// A completed verify filled a hash-less row from what it observed.
352    Backfill,
353}
354
355impl AnchorHashSource {
356    pub fn as_wire(self) -> &'static str {
357        match self {
358            AnchorHashSource::Author => "author",
359            AnchorHashSource::Backfill => "backfill",
360        }
361    }
362}
363
364// ---------------------------------------------------------------------------
365// Span locators
366// ---------------------------------------------------------------------------
367
368/// What a `span` anchor's locator (everything after the first `#`) selects.
369///
370/// Two forms are legal and the distinction is not cosmetic. A LINE RANGE is
371/// checkable against content the write path already holds; a UNIT KEY is a
372/// delivery preparation's own key (`dated-entries` writes
373/// `<path>#<iso-stamp>`), whose validity only that preparation can judge, and
374/// which the existing unit-absent refusal already covers where content is
375/// supplied. Anything the engine can check, it checks; anything it cannot, it
376/// records as unchecked.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum SpanLocator<'a> {
379    /// `L<start>` or `L<start>-L<end>`, 1-based and inclusive.
380    Lines { start: usize, end: usize },
381    /// A preparation's delivery-unit key, opaque here.
382    Unit(&'a str),
383}
384
385/// Parse a `span` artifact reference's locator, or say why it cannot be one.
386/// `Ok(None)` means the reference carries no locator at all.
387///
388/// **No locator is not a refusal**, and that is a deliberate line. A
389/// `span`-grain reference naming a bare path addresses its whole file, which
390/// is what a span's hash covers anyway with no preparation declared, and such
391/// anchors are written today. Refusing them would be a new wall across a
392/// working flow, which the plan's own criterion 4 forbids.
393///
394/// The refusals are the shapes that can never address anything: an EMPTY
395/// locator (`path#`, which announces a span and then names none), and a
396/// locator that announces itself as a line range by its `L` prefix and then
397/// contradicts itself (no digits, a zero line, an end before its start). A
398/// locator that does not look like a line range is a unit key and is accepted
399/// here, because this function cannot know a preparation's key grammar and
400/// refusing what it cannot judge would break every `dated-entries` anchor.
401pub fn parse_span_locator(artifact: &str) -> Result<Option<SpanLocator<'_>>, &'static str> {
402    let locator = match artifact.split_once('#') {
403        None => return Ok(None),
404        Some((_, loc)) if loc.trim().is_empty() => {
405            return Err("the span locator after `#` is empty");
406        }
407        Some((_, loc)) => loc,
408    };
409    // Only an `L`-prefixed locator claims to be a line range. Everything else
410    // is a unit key and is not this function's to judge.
411    let looks_like_lines = locator.starts_with('L')
412        && locator[1..]
413            .chars()
414            .next()
415            .is_some_and(|c| c.is_ascii_digit());
416    if !looks_like_lines {
417        return Ok(Some(SpanLocator::Unit(locator)));
418    }
419    let (start_raw, end_raw) = match locator.split_once('-') {
420        None => (locator, locator),
421        Some((a, b)) => (a, b),
422    };
423    let num = |part: &str| -> Option<usize> {
424        part.strip_prefix('L')
425            .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
426            .and_then(|d| d.parse::<usize>().ok())
427    };
428    let (Some(start), Some(end)) = (num(start_raw), num(end_raw)) else {
429        return Err("a line-range span locator must read `L<start>` or `L<start>-L<end>`");
430    };
431    if start == 0 {
432        return Err("line numbers are 1-based, so `L0` addresses nothing");
433    }
434    if end < start {
435        return Err("a line-range span locator ends before it starts");
436    }
437    Ok(Some(SpanLocator::Lines { start, end }))
438}
439
440// ---------------------------------------------------------------------------
441// Validation
442// ---------------------------------------------------------------------------
443
444/// A permissive wire-shaped anchor element as it arrives on a mutation's
445/// `anchors[]` parameter. All fields are optional / string-typed so an
446/// unknown class or grain surfaces as a typed [`AnchorValidationError`]
447/// with recovery detail rather than an opaque serde failure. Call
448/// [`Self::validate`] to obtain a strict [`Anchor`].
449#[derive(Debug, Clone, Default, Serialize, Deserialize)]
450pub struct AnchorInput {
451    #[serde(default)]
452    pub artifact: Option<String>,
453    #[serde(default)]
454    pub grain: Option<String>,
455    #[serde(default)]
456    pub class: Option<String>,
457    #[serde(default)]
458    pub at_version: Option<AnchorVersion>,
459    #[serde(default)]
460    pub hash: Option<String>,
461    /// The observed artifact CONTENT (UTF-8 text), for the engine to compute
462    /// `hash` from through its preparation registry
463    /// ([`crate::preparation::supplied_content_hash`]) — the write-time
464    /// observation for a grain the engine cannot observe itself: a `url`
465    /// anchor, because the engine never fetches. Accepted for the `span` /
466    /// `file` / `url` grains; mutually exclusive with `hash`; refused on a
467    /// non-hash class and on the `entity` / `tree` grains, whose prepared
468    /// form is never computed from supplied bytes.
469    #[serde(default)]
470    pub content: Option<String>,
471    #[serde(default)]
472    pub hash_stability: Option<String>,
473    #[serde(default)]
474    pub derived_from: Option<Vec<String>>,
475    #[serde(default)]
476    pub binding: Option<String>,
477    #[serde(default)]
478    pub source: Option<String>,
479}
480
481/// A permissive wire-shaped `anchors_unset[]` element — an explicit
482/// removal selector on the update surface. Each entry names an `artifact`
483/// and may narrow by `grain` and/or `class`; a bare artifact selects every
484/// anchor on it. String-typed like [`AnchorInput`] so an unknown grain or
485/// class refuses typed (`INVALID_ANCHOR`) rather than silently selecting
486/// nothing forever. Call [`Self::validate`] to obtain a strict
487/// [`AnchorUnset`].
488#[derive(Debug, Clone, Default, Serialize, Deserialize)]
489pub struct AnchorUnsetInput {
490    #[serde(default)]
491    pub artifact: Option<String>,
492    #[serde(default)]
493    pub grain: Option<String>,
494    #[serde(default)]
495    pub class: Option<String>,
496}
497
498impl AnchorUnsetInput {
499    /// Validate this wire element into a strict [`AnchorUnset`], or refuse
500    /// typed. Rules: artifact present and non-empty; grain / class, when
501    /// supplied, must be known wire strings (absent means "any").
502    pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
503        let artifact = self
504            .artifact
505            .as_deref()
506            .map(str::trim)
507            .filter(|s| !s.is_empty())
508            .map(str::to_string)
509            .ok_or(AnchorValidationError::MissingArtifact)?;
510        let grain = match self.grain.as_deref() {
511            None => None,
512            Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
513                AnchorValidationError::UnknownGrain {
514                    got: Some(s.to_string()),
515                    allowed: AnchorGrain::WIRE_VALUES,
516                }
517            })?),
518        };
519        let class = match self.class.as_deref() {
520            None => None,
521            Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
522                AnchorValidationError::UnknownClass {
523                    got: Some(s.to_string()),
524                    allowed: AnchorProvenanceClass::WIRE_VALUES,
525                }
526            })?),
527        };
528        Ok(AnchorUnset {
529            artifact,
530            grain,
531            class,
532        })
533    }
534}
535
536/// A validated explicit-removal selector: which of an entity's anchors an
537/// update's `anchors_unset[]` entry removes. Selection is by artifact,
538/// optionally narrowed by grain and/or class; a selector matching nothing
539/// is a no-op (removal is idempotent — its job in recovery flows is "make
540/// sure this is gone").
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct AnchorUnset {
543    /// Artifact reference to remove anchors from, exactly as stored.
544    pub artifact: String,
545    /// When present, only anchors of this grain are removed.
546    pub grain: Option<AnchorGrain>,
547    /// When present, only anchors of this class are removed.
548    pub class: Option<AnchorProvenanceClass>,
549}
550
551impl AnchorUnset {
552    /// Whether this selector removes `anchor`.
553    pub fn matches(&self, anchor: &Anchor) -> bool {
554        anchor.artifact == self.artifact
555            && self.grain.is_none_or(|g| anchor.grain == g)
556            && self.class.is_none_or(|c| anchor.class == c)
557    }
558}
559
560/// A typed `INVALID_ANCHOR` refusal. The whole mutation refuses and the
561/// entity is not written; [`Self::detail`] carries the recovery payload
562/// (offending value + allowed set) the agent fixes from.
563#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
564pub enum AnchorValidationError {
565    /// Provenance class is absent or not one of the allowed wire strings.
566    #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
567    UnknownClass {
568        got: Option<String>,
569        allowed: &'static [&'static str],
570    },
571    /// Grain is absent or not one of the allowed wire strings.
572    #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
573    UnknownGrain {
574        got: Option<String>,
575        allowed: &'static [&'static str],
576    },
577    /// Hash stability, when supplied, is not an allowed wire string.
578    #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
579    UnknownHashStability {
580        got: String,
581        allowed: &'static [&'static str],
582    },
583    /// The artifact reference is missing or empty.
584    #[error("anchor is missing its artifact reference")]
585    MissingArtifact,
586    /// A content hash (or content to hash) was supplied on a class that
587    /// carries no hash semantics (`authored` / `informed-by`).
588    #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
589    HashOnNonHashClass { class: &'static str },
590    /// Both `hash` and `content` were supplied — the engine computes the
591    /// hash from content, so a supplied hash beside it is ambiguous.
592    #[error(
593        "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
594    )]
595    ContentAndHash,
596    /// `content` was supplied for a grain whose prepared form is never
597    /// computed from supplied bytes: `entity` (computed from the live graph)
598    /// or `tree` (whose prepared form, under a code map, is enumerated by
599    /// the engine).
600    #[error(
601        "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
602         from supplied bytes (accepted for span / file / url)"
603    )]
604    ContentNotAcceptedForGrain { grain: &'static str },
605    /// `content` was supplied for a `<path>#<key>` unit under a delivery
606    /// preparation, but the content yields no unit with that key.
607    #[error(
608        "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
609         yield; supply the whole file's content, or address a unit it contains"
610    )]
611    UnitAbsentFromContent { artifact: String },
612    /// A `span`-grain anchor whose locator is missing, empty, or announces a
613    /// line range and then contradicts itself. Refused at write: such a row
614    /// can never address anything, and accepting it produces an anchor that
615    /// is unadjudicable from birth.
616    #[error("anchor artifact {artifact:?} is not a usable span reference: {reason}")]
617    SpanLocatorUnusable {
618        artifact: String,
619        reason: &'static str,
620    },
621    /// A `span`-grain anchor whose line range lies outside the content the
622    /// caller supplied. Only fires where content is in hand — the write path
623    /// reads no source, and where it cannot check, the row records that
624    /// instead (`span_unvalidated`).
625    #[error(
626        "anchor artifact {artifact:?} names lines the supplied `content` does not have \
627         (it has {lines} line(s)); address a range the artifact contains"
628    )]
629    SpanOutsideContent { artifact: String, lines: usize },
630    /// One payload named the same `(artifact, grain, class)` triple twice.
631    /// That triple is the sidecar's merge identity, so the later occurrence
632    /// silently replaced the earlier one and the caller was never told an
633    /// anchor it wrote had gone missing. A LATER call replacing the stored
634    /// row is unaffected: the unit of this refusal is one payload.
635    #[error(
636        "the anchors payload names {artifact:?} at grain `{grain}` and class `{class}` more \
637         than once; that triple is one row, so the repeats would silently collapse to the \
638         last one: send it once, or vary the grain or class"
639    )]
640    DuplicateAnchorTriple {
641        artifact: String,
642        grain: &'static str,
643        class: &'static str,
644    },
645    /// A `source` was supplied but is empty after trimming — a source
646    /// name, when present, must be one of the producing binding's
647    /// declared names, and an empty string can never be one.
648    #[error("anchor `source`, when present, must be a non-empty source name")]
649    EmptySource,
650    /// The anchor's `source` is not among the sources declared by its
651    /// own (resolvable) producing binding. Carries the declared names
652    /// as the recovery payload. Only fires when the `binding` hash
653    /// still resolves in this workspace — an orphaned or since-edited
654    /// binding accepts any non-empty name, deliberately: a legacy
655    /// anchor whose binding was renamed keeps writing as long as its
656    /// artifact reference is alive under the workspace-relative
657    /// fallback (`mem_commands::source_dialect_anchors_join_fallback_collide_and_refuse`).
658    #[error(
659        "anchor `source` {got:?} is not declared by the anchor's producing binding; \
660         declared sources: {}",
661        declared.join(", ")
662    )]
663    SourceNotDeclared { got: String, declared: Vec<String> },
664    /// A path-grain artifact reference that resolves under NO candidate
665    /// join — neither source-relative (joined onto the declaring source's
666    /// pointer, decision 26) nor workspace-relative. Refused at write time
667    /// so the mutation never stores a silently dead (orphaned-at-birth)
668    /// reference; the payload names every candidate tried so the agent can
669    /// fix the dialect.
670    #[error(
671        "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
672         paths are source-relative (joined onto the source's pointer) or workspace-relative — \
673         write the path exactly as the brief lists it",
674        candidates.join(", ")
675    )]
676    ArtifactUnresolvable {
677        artifact: String,
678        candidates: Vec<String>,
679    },
680    /// The grain cannot be expressed in the medium's anchor namespace
681    /// (per the E2 capability matrix), e.g. `span` on a non-path medium.
682    #[error(
683        "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
684         '{anchor_namespace}' namespace does not admit that grain"
685    )]
686    GrainNamespaceUnsupported {
687        grain: &'static str,
688        medium_type: String,
689        anchor_namespace: &'static str,
690    },
691}
692
693impl AnchorValidationError {
694    /// The stable typed code — always [`INVALID_ANCHOR_CODE`].
695    pub fn code(&self) -> &'static str {
696        INVALID_ANCHOR_CODE
697    }
698
699    /// Structured recovery detail for the typed envelope: the offending
700    /// field, its bad value, and the allowed set where one applies.
701    pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
702        let mut d = BTreeMap::new();
703        match self {
704            AnchorValidationError::UnknownClass { got, allowed } => {
705                d.insert("field".into(), "class".into());
706                d.insert("got".into(), serde_json::json!(got));
707                d.insert("allowed".into(), serde_json::json!(allowed));
708            }
709            AnchorValidationError::UnknownGrain { got, allowed } => {
710                d.insert("field".into(), "grain".into());
711                d.insert("got".into(), serde_json::json!(got));
712                d.insert("allowed".into(), serde_json::json!(allowed));
713            }
714            AnchorValidationError::UnknownHashStability { got, allowed } => {
715                d.insert("field".into(), "hash_stability".into());
716                d.insert("got".into(), serde_json::json!(got));
717                d.insert("allowed".into(), serde_json::json!(allowed));
718            }
719            AnchorValidationError::MissingArtifact => {
720                d.insert("field".into(), "artifact".into());
721            }
722            AnchorValidationError::EmptySource => {
723                d.insert("field".into(), "source".into());
724            }
725            AnchorValidationError::SourceNotDeclared { got, declared } => {
726                d.insert("field".into(), "source".into());
727                d.insert("got".into(), serde_json::json!(got));
728                d.insert("declared".into(), serde_json::json!(declared));
729            }
730            AnchorValidationError::HashOnNonHashClass { class } => {
731                d.insert("field".into(), "hash".into());
732                d.insert("class".into(), serde_json::json!(class));
733            }
734            AnchorValidationError::ContentAndHash => {
735                d.insert("field".into(), "content".into());
736                d.insert(
737                    "expected".into(),
738                    serde_json::json!("either `hash` or `content`, never both"),
739                );
740            }
741            AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
742                d.insert("field".into(), "content".into());
743                d.insert("grain".into(), serde_json::json!(grain));
744                d.insert(
745                    "accepted_grains".into(),
746                    serde_json::json!(["span", "file", "url"]),
747                );
748            }
749            AnchorValidationError::UnitAbsentFromContent { artifact } => {
750                d.insert("field".into(), "content".into());
751                d.insert("got".into(), serde_json::json!(artifact));
752            }
753            AnchorValidationError::SpanLocatorUnusable { artifact, reason } => {
754                d.insert("field".into(), "artifact".into());
755                d.insert("got".into(), serde_json::json!(artifact));
756                d.insert("expected".into(), serde_json::json!(reason));
757            }
758            AnchorValidationError::SpanOutsideContent { artifact, lines } => {
759                d.insert("field".into(), "artifact".into());
760                d.insert("got".into(), serde_json::json!(artifact));
761                d.insert("content_lines".into(), serde_json::json!(lines));
762            }
763            AnchorValidationError::DuplicateAnchorTriple {
764                artifact,
765                grain,
766                class,
767            } => {
768                d.insert("field".into(), "anchors".into());
769                d.insert(
770                    "got".into(),
771                    serde_json::json!({ "artifact": artifact, "grain": grain, "class": class }),
772                );
773                d.insert(
774                    "expected".into(),
775                    serde_json::json!(
776                        "each (artifact, grain, class) triple at most once per payload"
777                    ),
778                );
779            }
780            AnchorValidationError::ArtifactUnresolvable {
781                artifact,
782                candidates,
783            } => {
784                d.insert("field".into(), "artifact".into());
785                d.insert("got".into(), serde_json::json!(artifact));
786                d.insert("candidates_tried".into(), serde_json::json!(candidates));
787                d.insert(
788                    "expected".into(),
789                    serde_json::json!(
790                        "a source-relative path (joined onto the source's pointer) or a \
791                         workspace-relative path that resolves to an existing artifact"
792                    ),
793                );
794            }
795            AnchorValidationError::GrainNamespaceUnsupported {
796                grain,
797                medium_type,
798                anchor_namespace,
799            } => {
800                d.insert("field".into(), "grain".into());
801                d.insert("grain".into(), serde_json::json!(grain));
802                d.insert("medium_type".into(), serde_json::json!(medium_type));
803                d.insert(
804                    "anchor_namespace".into(),
805                    serde_json::json!(anchor_namespace),
806                );
807            }
808        }
809        d
810    }
811}
812
813impl AnchorInput {
814    /// Validate this wire element into a strict [`Anchor`], or refuse
815    /// typed.
816    ///
817    /// `medium` — the resolving medium's `(type_name, anchor_namespace)`
818    /// pair, when the mutation resolved one. When `Some`, the grain is
819    /// checked against the namespace (the capability-matrix refusal);
820    /// when `None` (no medium context — a manually-authored anchor), the
821    /// namespace check is skipped and only the vocabulary + hash-semantics
822    /// rules apply.
823    ///
824    /// Rules enforced (each a typed [`AnchorValidationError`]):
825    /// - class present and known;
826    /// - grain present and known;
827    /// - artifact reference present and non-empty;
828    /// - a hash (or content to hash) is supplied only on a hash-bearing
829    ///   class; `content` and `hash` are mutually exclusive; `content` is
830    ///   accepted only for the grains whose prepared form the registry
831    ///   computes from supplied bytes (`span` / `file` / `url`), and then
832    ///   `hash` is the registry's prepared hash of it;
833    /// - hash stability, when supplied, is a known wire string (defaults
834    ///   per grain when absent — `url` unstable, every other grain stable);
835    /// - grain supported by the medium's namespace (when `medium` given).
836    pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
837        let class = match self
838            .class
839            .as_deref()
840            .and_then(AnchorProvenanceClass::from_wire)
841        {
842            Some(c) => c,
843            None => {
844                return Err(AnchorValidationError::UnknownClass {
845                    got: self.class.clone(),
846                    allowed: AnchorProvenanceClass::WIRE_VALUES,
847                });
848            }
849        };
850        let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
851            Some(g) => g,
852            None => {
853                return Err(AnchorValidationError::UnknownGrain {
854                    got: self.grain.clone(),
855                    allowed: AnchorGrain::WIRE_VALUES,
856                });
857            }
858        };
859
860        let artifact = self
861            .artifact
862            .as_deref()
863            .map(str::trim)
864            .filter(|s| !s.is_empty())
865            .map(str::to_string)
866            .ok_or(AnchorValidationError::MissingArtifact)?;
867
868        // Hash stability: default per grain when absent (`url` unstable,
869        // every other grain stable); refuse an unknown supplied value.
870        let hash_stability = match self.hash_stability.as_deref() {
871            None => crate::preparation::default_hash_stability(grain),
872            Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
873                AnchorValidationError::UnknownHashStability {
874                    got: s.to_string(),
875                    allowed: AnchorHashStability::WIRE_VALUES,
876                }
877            })?,
878        };
879
880        // A hash is only meaningful on a hash-bearing class.
881        let hash = self
882            .hash
883            .as_deref()
884            .map(str::trim)
885            .filter(|s| !s.is_empty())
886            .map(str::to_string);
887        if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
888            return Err(AnchorValidationError::HashOnNonHashClass {
889                class: class.as_wire(),
890            });
891        }
892        // Supplied content: the engine computes the prepared hash through the
893        // preparation registry (touchpoint A at write time) — the one way a
894        // `url` anchor's recorded hash is ever the engine's prepared form.
895        let hash = match self.content.as_deref() {
896            None => hash,
897            Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
898            Some(content) => {
899                match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
900                    Some(h) => Some(h),
901                    None => {
902                        return Err(AnchorValidationError::ContentNotAcceptedForGrain {
903                            grain: grain.as_wire(),
904                        });
905                    }
906                }
907            }
908        };
909
910        // The span itself (consistency-sweep 03/03). A locator that can never
911        // address anything is refused here, context-free, because no medium
912        // context can rescue it. Where the caller supplied content, a line
913        // range is checked against it; where they did not, the row carries
914        // `span_unvalidated` so no later surface reports it as adjudicated.
915        let mut span_unvalidated = false;
916        if grain == AnchorGrain::Span {
917            let locator = parse_span_locator(&artifact).map_err(|reason| {
918                AnchorValidationError::SpanLocatorUnusable {
919                    artifact: artifact.clone(),
920                    reason,
921                }
922            })?;
923            match (locator, self.content.as_deref()) {
924                (Some(SpanLocator::Lines { end, .. }), Some(content)) => {
925                    let lines = content.lines().count();
926                    if end > lines {
927                        return Err(AnchorValidationError::SpanOutsideContent {
928                            artifact: artifact.clone(),
929                            lines,
930                        });
931                    }
932                }
933                // A unit key with content in hand is the existing
934                // unit-absent refusal's business, at the seam that knows the
935                // source's preparation; a check here would have to guess it.
936                (Some(SpanLocator::Unit(_)), Some(_)) => {}
937                // No locator addresses the whole artifact, and the existence
938                // gate already checks that the artifact is there. Nothing is
939                // left unchecked, so nothing is recorded as unchecked.
940                (None, _) => {}
941                (Some(_), None) => span_unvalidated = true,
942            }
943        }
944
945        // Grain must be expressible in the medium's namespace.
946        if let Some((medium_type, namespace)) = medium
947            && !grain.supported_by_namespace(namespace)
948        {
949            // Resolve the namespace to its `&'static str` so the error
950            // carries a stable value even though the input came borrowed.
951            let anchor_namespace = match namespace {
952                "path" => "path",
953                "path+commit" => "path+commit",
954                "entity" => "entity",
955                "url" => "url",
956                _ => "path",
957            };
958            return Err(AnchorValidationError::GrainNamespaceUnsupported {
959                grain: grain.as_wire(),
960                medium_type: medium_type.to_string(),
961                anchor_namespace,
962            });
963        }
964
965        // `source`, when present, must be non-empty. (Whether it names a
966        // source the producing binding actually declares is checked at
967        // the engine seam, which can resolve the binding hash — this
968        // context-free validator cannot.)
969        let source = match self.source.as_deref() {
970            None => None,
971            Some(raw) => {
972                let trimmed = raw.trim();
973                if trimmed.is_empty() {
974                    return Err(AnchorValidationError::EmptySource);
975                }
976                Some(trimmed.to_string())
977            }
978        };
979
980        Ok(Anchor {
981            artifact,
982            grain,
983            class,
984            at_version: self.at_version.clone(),
985            // A hash present at this point came from the writer, directly or
986            // as content the engine hashed. The backfill stamps its own.
987            hash_source: hash.is_some().then_some(AnchorHashSource::Author),
988            hash,
989            hash_stability,
990            derived_from: self.derived_from.clone().unwrap_or_default(),
991            binding: self
992                .binding
993                .as_deref()
994                .map(str::trim)
995                .filter(|s| !s.is_empty())
996                .map(str::to_string),
997            source,
998            span_unvalidated,
999        })
1000    }
1001}
1002
1003// ---------------------------------------------------------------------------
1004// Prepared-content hash
1005// ---------------------------------------------------------------------------
1006
1007/// Compute the **prepared-content hash** of a path-grain artifact's bytes —
1008/// the value [`Anchor::hash`] records and hash-drift adjudication compares.
1009///
1010/// The prepared form is a deliberate, minimal canonicalization that keeps the
1011/// hash stable across meaningless byte noise while preserving every
1012/// content-bearing byte. For UTF-8 text:
1013///
1014/// - a leading BOM (U+FEFF) is stripped;
1015/// - CRLF / lone-CR line endings normalize to LF;
1016/// - trailing newlines are trimmed (final-newline presence is noise).
1017///
1018/// Interior whitespace is untouched — trailing spaces inside a line can be
1019/// content (markdown hard breaks), so only the two classic cross-tool noise
1020/// sources (encoding marks, line-ending convention) and the final-newline
1021/// question are canonicalized. Non-UTF-8 (binary) bytes hash as-is — no text
1022/// canonicalization applies to them.
1023///
1024/// The hash form reuses the house convention — SHA-256, lowercase hex,
1025/// truncated to 16 characters — shared by entity content hashes
1026/// ([`crate::entity::parser::compute_hash`]) and the change-detection digest
1027/// aggregate, so the engine keeps one hash shape rather than growing a
1028/// second normalization.
1029pub fn prepared_content_hash(bytes: &[u8]) -> String {
1030    use sha2::{Digest as _, Sha256};
1031    let digest = match std::str::from_utf8(bytes) {
1032        Ok(text) => {
1033            let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1034            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1035            Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
1036        }
1037        Err(_) => Sha256::digest(bytes),
1038    };
1039    crate::hex_lower(&digest)[..16].to_string()
1040}
1041
1042/// One verify-observed prepared-content hash, addressed to the anchor(s) it
1043/// backfills: the `(entity, artifact)` pair a hash-less hash-bearing anchor
1044/// is keyed by in the sidecar, plus the hash the observation computed. The
1045/// verify pass collects these; the engine's sidecar writer records them.
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047pub struct ObservedArtifactHash {
1048    /// The entity id (`mem--slug`) whose anchor the hash belongs to.
1049    pub entity: String,
1050    /// The anchor's artifact reference, exactly as stored.
1051    pub artifact: String,
1052    /// The prepared-content hash observed for the artifact.
1053    pub hash: String,
1054}
1055
1056// ---------------------------------------------------------------------------
1057// Resolution
1058// ---------------------------------------------------------------------------
1059
1060/// The resolved state of one anchor against the current medium.
1061#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(rename_all = "lowercase")]
1063pub enum AnchorState {
1064    /// The artifact is present and matches (hash equal, or a non-hash
1065    /// class whose artifact still exists).
1066    Resolves,
1067    /// The artifact is present but its prepared-content hash differs and
1068    /// the medium is `stable` — a real content drift.
1069    Drifted,
1070    /// The artifact is present but drift cannot be asserted — the medium
1071    /// is `unstable`, or the hash is unavailable on one side. Flagged for
1072    /// re-examination, never reported as drift.
1073    Recheck,
1074    /// The artifact the anchor references is no longer present in the
1075    /// medium.
1076    Orphaned,
1077}
1078
1079impl AnchorState {
1080    /// Stable wire form.
1081    pub fn as_wire(&self) -> &'static str {
1082        match self {
1083            AnchorState::Resolves => "resolves",
1084            AnchorState::Drifted => "drifted",
1085            AnchorState::Recheck => "recheck",
1086            AnchorState::Orphaned => "orphaned",
1087        }
1088    }
1089}
1090
1091/// What the engine observed about an anchor's artifact when resolving.
1092#[derive(Debug, Clone, PartialEq, Eq)]
1093pub enum ArtifactObservation {
1094    /// The artifact could not be found in the medium.
1095    Absent,
1096    /// The artifact is present; `current_hash` is its prepared-content
1097    /// hash when the medium could compute one (`None` when the medium has
1098    /// no hash for it this pass — e.g. enumeration without preparation).
1099    Present { current_hash: Option<String> },
1100}
1101
1102/// Resolve one anchor against a current observation, honouring the class's
1103/// hash semantics and the medium's declared stability.
1104///
1105/// - `authored` / `informed-by` are excluded from hash-drift adjudication:
1106///   they [`Resolves`](AnchorState::Resolves) as long as the artifact
1107///   exists, [`Orphaned`](AnchorState::Orphaned) when it does not — a
1108///   content change never produces a drift state for them.
1109/// - `anchored` / `derived` compare the recorded prepared-content hash to
1110///   the current one: equal ⇒ resolves; different ⇒ `drifted` on a stable
1111///   medium, `recheck` on an unstable one; unavailable on either side ⇒
1112///   `recheck` (cannot adjudicate).
1113pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
1114    let current_hash = match observation {
1115        ArtifactObservation::Absent => return AnchorState::Orphaned,
1116        ArtifactObservation::Present { current_hash } => current_hash,
1117    };
1118    if !anchor.class.is_hash_bearing() {
1119        return AnchorState::Resolves;
1120    }
1121    match (&anchor.hash, current_hash) {
1122        (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
1123        (Some(_), Some(_)) => match anchor.hash_stability {
1124            AnchorHashStability::Stable => AnchorState::Drifted,
1125            AnchorHashStability::Unstable => AnchorState::Recheck,
1126        },
1127        // Missing hash on either side — cannot adjudicate drift.
1128        _ => AnchorState::Recheck,
1129    }
1130}
1131
1132/// Per-entity provenance-class + grain composition, computed from an
1133/// entity's anchor list. Tree-grain fan-out is surfaced distinctly so a
1134/// single entity anchored to a large tree is never laundered into
1135/// full per-file credit — the count of tree anchors is visible on its own
1136/// axis, and downstream (E3b) reads the fan-out counts from resolution.
1137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1138pub struct EntityAnchorComposition {
1139    /// Anchor count keyed by provenance-class wire string.
1140    pub by_class: BTreeMap<String, usize>,
1141    /// Anchor count keyed by grain wire string.
1142    pub by_grain: BTreeMap<String, usize>,
1143    /// The `derived_from` input lists of every `derived` anchor, in
1144    /// anchor order — E3b's derived-input provenance.
1145    pub derived_inputs: Vec<Vec<String>>,
1146    /// Artifact refs of every `tree`-grain anchor — the fan-out axis. A
1147    /// tree anchor is one row here regardless of how many files the tree
1148    /// contains; the file count is an observation resolution supplies, not
1149    /// a credit this composition grants.
1150    pub tree_grain_artifacts: Vec<String>,
1151}
1152
1153/// Compose an entity's anchors into class/grain counts, derived inputs,
1154/// and the tree-grain fan-out axis.
1155pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
1156    let mut comp = EntityAnchorComposition::default();
1157    for a in anchors {
1158        *comp
1159            .by_class
1160            .entry(a.class.as_wire().to_string())
1161            .or_insert(0) += 1;
1162        *comp
1163            .by_grain
1164            .entry(a.grain.as_wire().to_string())
1165            .or_insert(0) += 1;
1166        if a.class == AnchorProvenanceClass::Derived {
1167            comp.derived_inputs.push(a.derived_from.clone());
1168        }
1169        if a.grain == AnchorGrain::Tree {
1170            comp.tree_grain_artifacts.push(a.artifact.clone());
1171        }
1172    }
1173    comp
1174}
1175
1176// ---------------------------------------------------------------------------
1177// Sidecar document
1178// ---------------------------------------------------------------------------
1179
1180/// The engine-owned anchors sidecar document persisted at
1181/// [`ANCHOR_SIDECAR_PATH`] on the mem branch: entity id → its anchors.
1182///
1183/// Written only through engine commits (the [`crate::backend::MemBackend`]
1184/// sidecar seam). Rename rewrites the key atomically in the same commit as
1185/// the entity move; delete drops the key in the same commit as the entity
1186/// delete.
1187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1188pub struct AnchorSidecar {
1189    /// Document schema version.
1190    pub version: u32,
1191    /// Entity id (`mem--slug`) → its anchors. An entity with no anchors
1192    /// carries no key (an empty vec is pruned on write).
1193    #[serde(default)]
1194    pub entities: BTreeMap<String, Vec<Anchor>>,
1195}
1196
1197impl Default for AnchorSidecar {
1198    fn default() -> Self {
1199        Self {
1200            version: ANCHOR_SIDECAR_VERSION,
1201            entities: BTreeMap::new(),
1202        }
1203    }
1204}
1205
1206impl AnchorSidecar {
1207    /// Parse sidecar bytes; an absent/empty payload yields an empty
1208    /// document so callers need not special-case a fresh mem.
1209    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
1210        if bytes.iter().all(u8::is_ascii_whitespace) {
1211            return Ok(Self::default());
1212        }
1213        let sidecar: Self = serde_json::from_slice(bytes)?;
1214        // The version field is a contract, not decoration. Every sibling
1215        // store refuses an unknown one — the binding record with
1216        // `UNKNOWN_BINDING_VERSION`, the workspace stores with
1217        // `WORKSPACE_STORE_FORMAT_MISMATCH` — and this one silently accepted
1218        // it, so a sidecar written by a future engine parsed as whatever
1219        // today's field names happened to match and verified CLEAN. Reading
1220        // an unknown format optimistically is how a measurement ends up
1221        // confidently describing something it does not understand.
1222        if sidecar.version != ANCHOR_SIDECAR_VERSION {
1223            return Err(serde::de::Error::custom(format!(
1224                "unsupported anchors sidecar version {} (this engine reads version {}) — \
1225                 the file was written by a different engine; upgrade, or remove the sidecar \
1226                 to re-record anchors",
1227                sidecar.version, ANCHOR_SIDECAR_VERSION
1228            )));
1229        }
1230        Ok(sidecar)
1231    }
1232
1233    /// Serialise to canonical pretty JSON with a trailing newline —
1234    /// diff-friendly on the mem branch.
1235    pub fn to_bytes(&self) -> Vec<u8> {
1236        let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1237        s.push('\n');
1238        s.into_bytes()
1239    }
1240
1241    /// The anchors recorded for `entity_id`, or an empty slice.
1242    pub fn get(&self, entity_id: &str) -> &[Anchor] {
1243        self.entities
1244            .get(entity_id)
1245            .map(Vec::as_slice)
1246            .unwrap_or(&[])
1247    }
1248
1249    /// Replace `entity_id`'s anchors. An empty list prunes the key so the
1250    /// sidecar never accumulates empty rows.
1251    pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1252        if anchors.is_empty() {
1253            self.entities.remove(entity_id);
1254        } else {
1255            self.entities.insert(entity_id.to_string(), anchors);
1256        }
1257    }
1258
1259    /// Merge `incoming` into `entity_id`'s anchor row after applying
1260    /// `unsets` — the write-path set arithmetic.
1261    ///
1262    /// Unset applies **first**: each selector removes its matching anchors
1263    /// (a selector matching nothing is a no-op). Then each incoming anchor
1264    /// **replaces** the surviving anchor with the same
1265    /// `(artifact, grain, class)` triple in place, and **appends**
1266    /// otherwise — untouched anchors keep their bytes and their position.
1267    /// Writing anchors never removes an anchor the call did not name in
1268    /// `unsets`; an empty `incoming` merges nothing. A row emptied by
1269    /// unsets prunes its key so the sidecar never accumulates empty rows.
1270    pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
1271        let mut row = self.entities.remove(entity_id).unwrap_or_default();
1272        row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1273        for mut anchor in incoming {
1274            match row.iter_mut().find(|e| {
1275                e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1276            }) {
1277                Some(existing) => {
1278                    // Carry the stored baseline forward when the incoming row
1279                    // does not mention one (consistency-sweep 03/03,
1280                    // criterion 5). A re-pin usually exists to update the
1281                    // artifact reference, and dropping the hash made the next
1282                    // verify re-baseline silently, so drift became
1283                    // unfalsifiable with nothing recording that it had. A
1284                    // caller who supplies a hash still replaces it, and one
1285                    // who means to CLEAR a baseline unsets the row and writes
1286                    // it fresh — unsets are applied above, before this merge.
1287                    if anchor.hash.is_none()
1288                        && let Some(kept) = existing.hash.clone()
1289                    {
1290                        anchor.hash = Some(kept);
1291                        anchor.hash_source = existing.hash_source;
1292                    }
1293                    *existing = anchor;
1294                }
1295                None => row.push(anchor),
1296            }
1297        }
1298        if !row.is_empty() {
1299            self.entities.insert(entity_id.to_string(), row);
1300        }
1301    }
1302
1303    /// Blank every artifact reference — `artifact` and each `derived_from`
1304    /// entry — to [`REDACTED_ARTIFACT_SENTINEL`], keeping everything else:
1305    /// class, grain, `at_version`, hash, hash-stability, binding, source,
1306    /// and the per-entity anchor counts. Redact, not strip: a consumer
1307    /// still reads *how strongly* each entity claims fidelity to a source
1308    /// without learning *which* source. Publish-time only by design — no
1309    /// engine path calls this against workspace state.
1310    pub fn redact_artifact_references(&mut self) {
1311        for anchors in self.entities.values_mut() {
1312            for anchor in anchors {
1313                anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1314                for input in &mut anchor.derived_from {
1315                    *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1316                }
1317            }
1318        }
1319    }
1320
1321    /// Structural check on artifact references: every `artifact` and every
1322    /// `derived_from` entry must be non-empty. The mutation surface never
1323    /// admits an empty reference (`INVALID_ANCHOR`), so a sidecar carrying
1324    /// one is corruption — including a botched redaction that blanked to
1325    /// nothing instead of the pinned sentinel. Returns the first offence.
1326    pub fn validate_artifact_references(&self) -> Result<(), String> {
1327        for (entity_id, anchors) in &self.entities {
1328            for anchor in anchors {
1329                if anchor.artifact.trim().is_empty() {
1330                    return Err(format!(
1331                        "entity `{entity_id}` carries an anchor with an empty artifact \
1332                         reference"
1333                    ));
1334                }
1335                if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1336                    return Err(format!(
1337                        "entity `{entity_id}` carries an anchor with an empty \
1338                         `derived_from` entry"
1339                    ));
1340                }
1341            }
1342        }
1343        Ok(())
1344    }
1345
1346    /// Drop `entity_id`'s anchors entirely (delete leg). Idempotent.
1347    pub fn remove(&mut self, entity_id: &str) {
1348        self.entities.remove(entity_id);
1349    }
1350
1351    /// Move `from`'s anchors to `to` (rename leg), leaving zero rows under
1352    /// the old id. No-op when `from` has no anchors. When `to` already has
1353    /// anchors they are overwritten — a rename onto a live id is refused
1354    /// upstream, so this is the residual-stub case only.
1355    pub fn rename(&mut self, from: &str, to: &str) {
1356        if let Some(anchors) = self.entities.remove(from) {
1357            self.entities.insert(to.to_string(), anchors);
1358        }
1359    }
1360
1361    /// Whether the document holds no anchors for any entity.
1362    pub fn is_empty(&self) -> bool {
1363        self.entities.is_empty()
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370
1371    /// Redaction blanks exactly the two artifact-reference fields — to the
1372    /// pinned sentinel, never removal — and keeps everything else: class,
1373    /// grain, `at_version`, hash, hash-stability, binding, source, and the
1374    /// per-entity anchor counts.
1375    #[test]
1376    fn redaction_blanks_references_and_keeps_trust_metadata() {
1377        let mut sidecar = AnchorSidecar::default();
1378        sidecar.set(
1379            "m--alpha",
1380            vec![
1381                Anchor {
1382                    artifact: "src/lib.rs".into(),
1383                    grain: AnchorGrain::File,
1384                    class: AnchorProvenanceClass::Anchored,
1385                    at_version: Some(AnchorVersion::Commit("abc123".into())),
1386                    hash: Some("h1".into()),
1387                    hash_stability: AnchorHashStability::Stable,
1388                    derived_from: vec![],
1389                    binding: Some("bhash".into()),
1390                    source: Some("source-tree".into()),
1391                    span_unvalidated: false,
1392                    hash_source: None,
1393                },
1394                Anchor {
1395                    artifact: "docs/summary.md".into(),
1396                    grain: AnchorGrain::File,
1397                    class: AnchorProvenanceClass::Derived,
1398                    at_version: None,
1399                    hash: Some("h2".into()),
1400                    hash_stability: AnchorHashStability::Unstable,
1401                    derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1402                    binding: None,
1403                    source: None,
1404                    span_unvalidated: false,
1405                    hash_source: None,
1406                },
1407            ],
1408        );
1409
1410        sidecar.redact_artifact_references();
1411
1412        let anchors = sidecar.get("m--alpha");
1413        assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1414        for a in anchors {
1415            assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1416            for d in &a.derived_from {
1417                assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1418            }
1419        }
1420        assert_eq!(
1421            anchors[0].at_version,
1422            Some(AnchorVersion::Commit("abc123".into()))
1423        );
1424        assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1425        assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1426        assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1427        assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1428        assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1429        // A redacted sidecar is structurally valid — the sentinel is not
1430        // an empty reference.
1431        sidecar.validate_artifact_references().unwrap();
1432    }
1433
1434    /// The structural reference check refuses empty `artifact` and empty
1435    /// `derived_from` entries — including a botched redaction that blanked
1436    /// to nothing instead of the sentinel.
1437    #[test]
1438    fn empty_artifact_references_are_refused() {
1439        let mut sidecar = AnchorSidecar::default();
1440        sidecar.set(
1441            "m--alpha",
1442            vec![Anchor {
1443                artifact: "".into(),
1444                grain: AnchorGrain::File,
1445                class: AnchorProvenanceClass::Anchored,
1446                at_version: None,
1447                hash: None,
1448                hash_stability: AnchorHashStability::Stable,
1449                derived_from: vec![],
1450                binding: None,
1451                source: None,
1452                span_unvalidated: false,
1453                hash_source: None,
1454            }],
1455        );
1456        assert!(sidecar.validate_artifact_references().is_err());
1457
1458        let mut sidecar = AnchorSidecar::default();
1459        sidecar.set(
1460            "m--beta",
1461            vec![Anchor {
1462                artifact: "docs/x.md".into(),
1463                grain: AnchorGrain::File,
1464                class: AnchorProvenanceClass::Derived,
1465                at_version: None,
1466                hash: None,
1467                hash_stability: AnchorHashStability::Stable,
1468                derived_from: vec!["  ".into()],
1469                binding: None,
1470                source: None,
1471                span_unvalidated: false,
1472                hash_source: None,
1473            }],
1474        );
1475        assert!(sidecar.validate_artifact_references().is_err());
1476    }
1477
1478    // -- wire vocabulary is the contract -----------------------------------
1479
1480    #[test]
1481    fn class_wire_strings_are_stable() {
1482        assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1483        assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1484        assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1485        assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1486        for w in AnchorProvenanceClass::WIRE_VALUES {
1487            assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1488        }
1489        assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1490    }
1491
1492    #[test]
1493    fn grain_wire_strings_are_stable() {
1494        for w in AnchorGrain::WIRE_VALUES {
1495            assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1496        }
1497        assert_eq!(
1498            AnchorGrain::WIRE_VALUES,
1499            &["span", "file", "tree", "url", "entity"]
1500        );
1501        assert!(AnchorGrain::from_wire("chunk").is_none());
1502    }
1503
1504    #[test]
1505    fn stability_and_state_wire_strings_are_stable() {
1506        assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1507        assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1508        assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1509        assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1510        assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1511        assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1512    }
1513
1514    #[test]
1515    fn only_anchored_and_derived_are_hash_bearing() {
1516        assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1517        assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1518        assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1519        assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1520    }
1521
1522    // -- grain / namespace matrix ------------------------------------------
1523
1524    #[test]
1525    fn grain_namespace_support_matches_capability_matrix() {
1526        // path-shaped grains need path / path+commit.
1527        for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1528            assert!(g.supported_by_namespace("path"));
1529            assert!(g.supported_by_namespace("path+commit"));
1530            assert!(!g.supported_by_namespace("url"));
1531            assert!(!g.supported_by_namespace("entity"));
1532        }
1533        assert!(AnchorGrain::Url.supported_by_namespace("url"));
1534        assert!(!AnchorGrain::Url.supported_by_namespace("path"));
1535        assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1536        assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1537    }
1538
1539    // -- validation refusals -----------------------------------------------
1540
1541    fn valid_input() -> AnchorInput {
1542        AnchorInput {
1543            artifact: Some("src/lib.rs".into()),
1544            grain: Some("file".into()),
1545            class: Some("anchored".into()),
1546            hash_stability: Some("stable".into()),
1547            hash: Some("abc123".into()),
1548            ..Default::default()
1549        }
1550    }
1551
1552    fn span_input(artifact: &str) -> AnchorInput {
1553        AnchorInput {
1554            artifact: Some(artifact.into()),
1555            grain: Some("span".into()),
1556            class: Some("anchored".into()),
1557            ..Default::default()
1558        }
1559    }
1560
1561    /// Criterion 1 (consistency-sweep 03/03): a locator that can never
1562    /// address anything is refused at the moment of writing. Each of these
1563    /// used to write successfully and could then never be adjudicated.
1564    #[test]
1565    fn a_span_locator_that_addresses_nothing_is_refused() {
1566        for artifact in [
1567            "src/lib.rs#",      // announces a span, names none
1568            "src/lib.rs#   ",   // the same, in whitespace
1569            "src/lib.rs#L0",    // lines are 1-based
1570            "src/lib.rs#L0-L4", // and so is a range's start
1571            "src/lib.rs#L9-L2", // ends before it starts
1572            "src/lib.rs#L4-L",  // half a range
1573            "src/lib.rs#L4-x",  // a range that stops being one
1574        ] {
1575            let err = span_input(artifact)
1576                .validate(Some(("codebase", "path")))
1577                .expect_err(artifact);
1578            assert!(
1579                matches!(err, AnchorValidationError::SpanLocatorUnusable { .. }),
1580                "{artifact} refused as {err:?}"
1581            );
1582            assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1583            assert!(err.detail().contains_key("expected"), "carries the repair");
1584        }
1585    }
1586
1587    /// Criterion 4's first half: a span that names something real still
1588    /// writes. `Lx` forms within the artifact, a preparation's unit key, and
1589    /// a bare path (which addresses the whole file, and is what a span's hash
1590    /// covers anyway) are all legal.
1591    #[test]
1592    fn a_usable_span_locator_still_writes() {
1593        for artifact in [
1594            "src/lib.rs",
1595            "src/lib.rs#L1",
1596            "src/lib.rs#L4-L7",
1597            "logs/ops.md#2026-08-25T00:00:00",
1598        ] {
1599            span_input(artifact)
1600                .validate(Some(("codebase", "path")))
1601                .unwrap_or_else(|e| panic!("{artifact} refused: {e}"));
1602        }
1603    }
1604
1605    /// Criterion 2: where the content is already in hand, a range beyond the
1606    /// artifact's end is refused rather than stored as an anchor pointing at
1607    /// lines the file does not have.
1608    #[test]
1609    fn a_span_beyond_supplied_content_is_refused() {
1610        let mut i = span_input("src/lib.rs#L2-L9");
1611        i.content = Some(
1612            "one
1613two
1614three
1615"
1616            .into(),
1617        );
1618        let err = i.validate(Some(("codebase", "path"))).unwrap_err();
1619        match err {
1620            AnchorValidationError::SpanOutsideContent { lines, .. } => assert_eq!(lines, 3),
1621            other => panic!("wrong refusal: {other:?}"),
1622        }
1623
1624        let mut ok = span_input("src/lib.rs#L2-L3");
1625        ok.content = Some(
1626            "one
1627two
1628three
1629"
1630            .into(),
1631        );
1632        let a = ok.validate(Some(("codebase", "path"))).unwrap();
1633        assert!(
1634            !a.span_unvalidated,
1635            "a span checked against content is not unvalidated"
1636        );
1637    }
1638
1639    /// Criterion 3: where the write path holds no content, the span cannot be
1640    /// checked without a read it deliberately does not perform. The anchor is
1641    /// accepted and the row says the span is unverified, so no later surface
1642    /// reports it as adjudicated.
1643    #[test]
1644    fn an_uncheckable_span_is_accepted_and_recorded_as_unchecked() {
1645        let a = span_input("src/lib.rs#L4-L7")
1646            .validate(Some(("codebase", "path")))
1647            .unwrap();
1648        assert!(a.span_unvalidated);
1649
1650        let whole_file = span_input("src/lib.rs")
1651            .validate(Some(("codebase", "path")))
1652            .unwrap();
1653        assert!(
1654            !whole_file.span_unvalidated,
1655            "no locator addresses the whole artifact, which the existence gate checks"
1656        );
1657
1658        let file_grain = valid_input().validate(Some(("codebase", "path"))).unwrap();
1659        assert!(!file_grain.span_unvalidated, "never set off the span grain");
1660    }
1661
1662    /// Criterion 8: a hash the writer supplied is recorded as theirs, so a
1663    /// reader can later tell it from one the backfill inferred.
1664    #[test]
1665    fn an_authored_hash_records_that_the_author_pinned_it() {
1666        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1667        assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
1668
1669        let mut hashless = valid_input();
1670        hashless.hash = None;
1671        let b = hashless.validate(Some(("codebase", "path"))).unwrap();
1672        assert_eq!(b.hash_source, None, "no baseline, no origin to record");
1673    }
1674
1675    /// Criteria 5 and 6: a re-pin that says nothing about the hash keeps the
1676    /// baseline it did not mention, one that supplies a hash replaces it, and
1677    /// unsetting the row first is the explicit way to clear it.
1678    #[test]
1679    fn a_re_pin_keeps_the_baseline_it_did_not_mention() {
1680        let mut sc = AnchorSidecar::default();
1681        let mut pinned = file_anchor("src/a.rs", "h-original");
1682        pinned.hash_source = Some(AnchorHashSource::Author);
1683        sc.set("m--e", vec![pinned]);
1684
1685        let mut repin = file_anchor("src/a.rs", "");
1686        repin.hash = None;
1687        repin.hash_source = None;
1688        sc.merge("m--e", &[], vec![repin]);
1689        let row = &sc.entities["m--e"][0];
1690        assert_eq!(
1691            row.hash.as_deref(),
1692            Some("h-original"),
1693            "the baseline the caller did not mention survives"
1694        );
1695        assert_eq!(row.hash_source, Some(AnchorHashSource::Author));
1696
1697        sc.merge("m--e", &[], vec![file_anchor("src/a.rs", "h-new")]);
1698        assert_eq!(
1699            sc.entities["m--e"][0].hash.as_deref(),
1700            Some("h-new"),
1701            "a supplied hash still replaces"
1702        );
1703
1704        // The explicit clear: unset the row, then write it fresh. Unsets are
1705        // applied before the merge, so the old row is gone first.
1706        let unset = AnchorUnset {
1707            artifact: "src/a.rs".into(),
1708            grain: None,
1709            class: None,
1710        };
1711        let mut fresh = file_anchor("src/a.rs", "");
1712        fresh.hash = None;
1713        fresh.hash_source = None;
1714        sc.merge("m--e", &[unset], vec![fresh]);
1715        assert_eq!(
1716            sc.entities["m--e"][0].hash, None,
1717            "unset-then-write is how a caller clears a baseline"
1718        );
1719    }
1720
1721    #[test]
1722    fn validate_accepts_a_well_formed_anchor() {
1723        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
1724        assert_eq!(a.artifact, "src/lib.rs");
1725        assert_eq!(a.grain, AnchorGrain::File);
1726        assert_eq!(a.class, AnchorProvenanceClass::Anchored);
1727        assert_eq!(a.hash.as_deref(), Some("abc123"));
1728        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
1729    }
1730
1731    /// Path grains keep their `stable` default — pinned, because the
1732    /// per-grain default that gives `url` its `unstable` must not leak.
1733    #[test]
1734    fn validate_defaults_hash_stability_to_stable() {
1735        for grain in ["span", "file", "tree"] {
1736            let mut i = valid_input();
1737            i.grain = Some(grain.into());
1738            i.hash_stability = None;
1739            let a = i.validate(None).unwrap();
1740            assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
1741        }
1742        let mut e = valid_input();
1743        e.grain = Some("entity".into());
1744        e.artifact = Some("m--e".into());
1745        e.hash_stability = None;
1746        assert_eq!(
1747            e.validate(None).unwrap().hash_stability,
1748            AnchorHashStability::Stable
1749        );
1750    }
1751
1752    /// A `url` anchor defaults to `unstable` (a served page is a moving
1753    /// target — a hash break resolves `recheck`, never `drifted`) unless the
1754    /// author asserts `stable`.
1755    #[test]
1756    fn validate_defaults_url_grain_to_unstable_unless_declared() {
1757        let mut i = valid_input();
1758        i.grain = Some("url".into());
1759        i.artifact = Some("https://example.invalid/doc".into());
1760        i.hash_stability = None;
1761        assert_eq!(
1762            i.validate(None).unwrap().hash_stability,
1763            AnchorHashStability::Unstable
1764        );
1765        i.hash_stability = Some("stable".into());
1766        assert_eq!(
1767            i.validate(None).unwrap().hash_stability,
1768            AnchorHashStability::Stable
1769        );
1770    }
1771
1772    /// Supplied `content` becomes the registry's prepared hash: for a `url`
1773    /// anchor the same canonicalization the path grains use over what the
1774    /// observer read; for `file`/`span` the hash the engine would compute
1775    /// from the file itself. `hash` beside it is refused, as is content on
1776    /// a grain the registry never prepares from bytes, or on a non-hash
1777    /// class.
1778    #[test]
1779    fn content_yields_the_prepared_hash_through_the_registry() {
1780        let mut u = valid_input();
1781        u.grain = Some("url".into());
1782        u.artifact = Some("https://example.invalid/doc".into());
1783        u.hash = None;
1784        u.hash_stability = None;
1785        u.content = Some("<p>hello</p>\r\n".into());
1786        let a = u.validate(None).unwrap();
1787        assert_eq!(
1788            a.hash.as_deref(),
1789            Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
1790        );
1791        assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
1792
1793        let mut f = valid_input();
1794        f.hash = None;
1795        f.content = Some("fn a() {}\n".into());
1796        assert_eq!(
1797            f.validate(None).unwrap().hash.as_deref(),
1798            Some(prepared_content_hash(b"fn a() {}").as_str())
1799        );
1800
1801        let mut both = valid_input();
1802        both.content = Some("x".into());
1803        assert_eq!(
1804            both.validate(None).unwrap_err(),
1805            AnchorValidationError::ContentAndHash
1806        );
1807
1808        let mut ent = valid_input();
1809        ent.grain = Some("entity".into());
1810        ent.artifact = Some("m--e".into());
1811        ent.hash = None;
1812        ent.content = Some("x".into());
1813        let err = ent.validate(None).unwrap_err();
1814        assert_eq!(
1815            err,
1816            AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
1817        );
1818        assert_eq!(err.detail()["field"], "content");
1819
1820        let mut tree = valid_input();
1821        tree.grain = Some("tree".into());
1822        tree.hash = None;
1823        tree.content = Some("x".into());
1824        assert!(matches!(
1825            tree.validate(None).unwrap_err(),
1826            AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
1827        ));
1828
1829        let mut informed = valid_input();
1830        informed.class = Some("informed-by".into());
1831        informed.hash = None;
1832        informed.content = Some("x".into());
1833        assert!(matches!(
1834            informed.validate(None).unwrap_err(),
1835            AnchorValidationError::HashOnNonHashClass { .. }
1836        ));
1837    }
1838
1839    #[test]
1840    fn validate_refuses_unknown_class() {
1841        let mut i = valid_input();
1842        i.class = Some("guessed".into());
1843        let err = i.validate(None).unwrap_err();
1844        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1845        assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
1846        assert_eq!(err.detail()["field"], serde_json::json!("class"));
1847    }
1848
1849    #[test]
1850    fn validate_refuses_unknown_grain() {
1851        let mut i = valid_input();
1852        i.grain = Some("paragraph".into());
1853        let err = i.validate(None).unwrap_err();
1854        assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
1855    }
1856
1857    #[test]
1858    fn validate_refuses_missing_artifact() {
1859        let mut i = valid_input();
1860        i.artifact = Some("   ".into());
1861        let err = i.validate(None).unwrap_err();
1862        assert!(matches!(err, AnchorValidationError::MissingArtifact));
1863        i.artifact = None;
1864        assert!(matches!(
1865            valid_input_with_artifact(None).validate(None).unwrap_err(),
1866            AnchorValidationError::MissingArtifact
1867        ));
1868        let _ = i;
1869    }
1870
1871    fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
1872        AnchorInput {
1873            artifact: a,
1874            ..valid_input()
1875        }
1876    }
1877
1878    #[test]
1879    fn validate_refuses_hash_on_non_hash_class() {
1880        let mut i = valid_input();
1881        i.class = Some("authored".into());
1882        // hash still supplied → refuse
1883        let err = i.validate(None).unwrap_err();
1884        assert!(matches!(
1885            err,
1886            AnchorValidationError::HashOnNonHashClass { class: "authored" }
1887        ));
1888    }
1889
1890    #[test]
1891    fn validate_accepts_non_hash_class_without_hash() {
1892        let mut i = valid_input();
1893        i.class = Some("informed-by".into());
1894        i.hash = None;
1895        let a = i.validate(None).unwrap();
1896        assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
1897        assert!(a.hash.is_none());
1898    }
1899
1900    #[test]
1901    fn validate_refuses_grain_unsupported_by_medium_namespace() {
1902        // span grain on a web (url namespace) medium.
1903        let mut i = valid_input();
1904        i.grain = Some("span".into());
1905        i.class = Some("authored".into());
1906        i.hash = None;
1907        let err = i.validate(Some(("web", "url"))).unwrap_err();
1908        match err {
1909            AnchorValidationError::GrainNamespaceUnsupported {
1910                grain,
1911                anchor_namespace,
1912                ..
1913            } => {
1914                assert_eq!(grain, "span");
1915                assert_eq!(anchor_namespace, "url");
1916            }
1917            other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
1918        }
1919    }
1920
1921    #[test]
1922    fn validate_skips_namespace_check_without_medium_context() {
1923        // span grain, no medium → namespace rule not applied.
1924        let mut i = valid_input();
1925        i.grain = Some("span".into());
1926        assert!(i.validate(None).is_ok());
1927    }
1928
1929    // -- prepared-content hash ----------------------------------------------
1930
1931    /// The prepared form is stable across meaningless byte noise: BOM,
1932    /// line-ending convention, and final-newline presence never move the
1933    /// hash — a real content change always does.
1934    #[test]
1935    fn prepared_hash_is_stable_across_byte_noise() {
1936        let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
1937        // CRLF and lone-CR line endings normalize away.
1938        assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
1939        assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
1940        // Final-newline presence (missing, single, several) is noise.
1941        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
1942        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
1943        // A leading UTF-8 BOM is stripped.
1944        assert_eq!(
1945            prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
1946            base
1947        );
1948        // A real content change moves the hash.
1949        assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
1950        // House hash shape: 16 lowercase hex chars.
1951        assert_eq!(base.len(), 16);
1952        assert!(
1953            base.chars()
1954                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1955        );
1956    }
1957
1958    /// Interior whitespace is content, not noise: a trailing space inside a
1959    /// line (markdown hard break) changes the hash.
1960    #[test]
1961    fn prepared_hash_preserves_interior_whitespace() {
1962        assert_ne!(
1963            prepared_content_hash(b"line one  \nline two\n"),
1964            prepared_content_hash(b"line one\nline two\n")
1965        );
1966    }
1967
1968    /// Non-UTF-8 bytes hash raw — no text canonicalization is applied, and
1969    /// any byte change moves the hash.
1970    #[test]
1971    fn prepared_hash_hashes_binary_bytes_raw() {
1972        let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
1973        let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
1974        assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
1975        // Deterministic.
1976        assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
1977    }
1978
1979    // -- resolution --------------------------------------------------------
1980
1981    fn anchor(
1982        class: AnchorProvenanceClass,
1983        hash: Option<&str>,
1984        stab: AnchorHashStability,
1985    ) -> Anchor {
1986        Anchor {
1987            artifact: "src/lib.rs".into(),
1988            grain: AnchorGrain::File,
1989            class,
1990            at_version: None,
1991            hash: hash.map(str::to_string),
1992            hash_stability: stab,
1993            derived_from: Vec::new(),
1994            binding: None,
1995            source: None,
1996            span_unvalidated: false,
1997            hash_source: None,
1998        }
1999    }
2000
2001    #[test]
2002    fn resolves_when_hash_matches() {
2003        let a = anchor(
2004            AnchorProvenanceClass::Anchored,
2005            Some("h1"),
2006            AnchorHashStability::Stable,
2007        );
2008        let obs = ArtifactObservation::Present {
2009            current_hash: Some("h1".into()),
2010        };
2011        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2012    }
2013
2014    #[test]
2015    fn stable_hash_break_drifts_unstable_rechecks() {
2016        let stable = anchor(
2017            AnchorProvenanceClass::Anchored,
2018            Some("h1"),
2019            AnchorHashStability::Stable,
2020        );
2021        let unstable = anchor(
2022            AnchorProvenanceClass::Anchored,
2023            Some("h1"),
2024            AnchorHashStability::Unstable,
2025        );
2026        let obs = ArtifactObservation::Present {
2027            current_hash: Some("h2".into()),
2028        };
2029        assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
2030        assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
2031    }
2032
2033    #[test]
2034    fn absent_artifact_is_orphaned() {
2035        let a = anchor(
2036            AnchorProvenanceClass::Anchored,
2037            Some("h1"),
2038            AnchorHashStability::Stable,
2039        );
2040        assert_eq!(
2041            resolve_anchor(&a, &ArtifactObservation::Absent),
2042            AnchorState::Orphaned
2043        );
2044    }
2045
2046    #[test]
2047    fn non_hash_classes_never_drift() {
2048        for class in [
2049            AnchorProvenanceClass::Authored,
2050            AnchorProvenanceClass::InformedBy,
2051        ] {
2052            let a = anchor(class, None, AnchorHashStability::Stable);
2053            // Content moved underneath — still resolves (excluded from
2054            // hash-drift adjudication).
2055            let obs = ArtifactObservation::Present {
2056                current_hash: Some("whatever".into()),
2057            };
2058            assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2059            // But an absent artifact is still orphaned.
2060            assert_eq!(
2061                resolve_anchor(&a, &ArtifactObservation::Absent),
2062                AnchorState::Orphaned
2063            );
2064        }
2065    }
2066
2067    #[test]
2068    fn unavailable_hash_rechecks_not_drifts() {
2069        let a = anchor(
2070            AnchorProvenanceClass::Anchored,
2071            Some("h1"),
2072            AnchorHashStability::Stable,
2073        );
2074        let obs = ArtifactObservation::Present { current_hash: None };
2075        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
2076    }
2077
2078    // -- composition -------------------------------------------------------
2079
2080    #[test]
2081    fn composition_counts_classes_grains_and_tree_fanout() {
2082        let anchors = vec![
2083            Anchor {
2084                artifact: "a.rs".into(),
2085                grain: AnchorGrain::File,
2086                class: AnchorProvenanceClass::Anchored,
2087                at_version: None,
2088                hash: Some("h".into()),
2089                hash_stability: AnchorHashStability::Stable,
2090                derived_from: Vec::new(),
2091                binding: None,
2092                source: None,
2093                span_unvalidated: false,
2094                hash_source: None,
2095            },
2096            Anchor {
2097                artifact: "src/".into(),
2098                grain: AnchorGrain::Tree,
2099                class: AnchorProvenanceClass::Derived,
2100                at_version: None,
2101                hash: Some("t".into()),
2102                hash_stability: AnchorHashStability::Stable,
2103                derived_from: vec!["a.rs".into(), "b.rs".into()],
2104                binding: None,
2105                source: None,
2106                span_unvalidated: false,
2107                hash_source: None,
2108            },
2109        ];
2110        let comp = compose_entity_anchors(&anchors);
2111        assert_eq!(comp.by_class["anchored"], 1);
2112        assert_eq!(comp.by_class["derived"], 1);
2113        assert_eq!(comp.by_grain["file"], 1);
2114        assert_eq!(comp.by_grain["tree"], 1);
2115        // Tree fan-out is a distinct axis — one row, never per-file credit.
2116        assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
2117        assert_eq!(
2118            comp.derived_inputs,
2119            vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
2120        );
2121    }
2122
2123    // -- sidecar round-trip -------------------------------------------------
2124
2125    #[test]
2126    fn sidecar_round_trips_and_prunes_empty() {
2127        let mut sc = AnchorSidecar::default();
2128        assert!(sc.is_empty());
2129        let a = anchor(
2130            AnchorProvenanceClass::Anchored,
2131            Some("h1"),
2132            AnchorHashStability::Stable,
2133        );
2134        sc.set("specs--x", vec![a.clone()]);
2135        assert_eq!(sc.get("specs--x").len(), 1);
2136
2137        let bytes = sc.to_bytes();
2138        let round = AnchorSidecar::from_bytes(&bytes).unwrap();
2139        assert_eq!(round, sc);
2140
2141        // Setting empty prunes the key.
2142        sc.set("specs--x", vec![]);
2143        assert!(sc.is_empty());
2144        assert!(sc.get("specs--x").is_empty());
2145    }
2146
2147    // -- merge / unset arithmetic ------------------------------------------
2148
2149    fn file_anchor(artifact: &str, hash: &str) -> Anchor {
2150        Anchor {
2151            artifact: artifact.into(),
2152            grain: AnchorGrain::File,
2153            class: AnchorProvenanceClass::Anchored,
2154            at_version: None,
2155            hash: Some(hash.into()),
2156            hash_stability: AnchorHashStability::Stable,
2157            derived_from: Vec::new(),
2158            binding: None,
2159            source: None,
2160            span_unvalidated: false,
2161            hash_source: None,
2162        }
2163    }
2164
2165    /// Merge appends a new triple and leaves the existing set untouched —
2166    /// the incremental-anchoring contract (N existing + 1 new ⇒ N+1).
2167    #[test]
2168    fn merge_appends_new_triple_without_touching_others() {
2169        let mut sc = AnchorSidecar::default();
2170        sc.set(
2171            "m--e",
2172            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2173        );
2174        sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
2175        let row = sc.get("m--e");
2176        assert_eq!(row.len(), 3);
2177        assert_eq!(row[0], file_anchor("a.rs", "h-a"));
2178        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2179        assert_eq!(row[2], file_anchor("c.rs", "h-c"));
2180    }
2181
2182    /// An incoming anchor with an existing `(artifact, grain, class)`
2183    /// triple replaces exactly that one, in place; others stay
2184    /// byte-identical.
2185    #[test]
2186    fn merge_replaces_same_triple_in_place() {
2187        let mut sc = AnchorSidecar::default();
2188        sc.set(
2189            "m--e",
2190            vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
2191        );
2192        sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
2193        let row = sc.get("m--e");
2194        assert_eq!(row.len(), 2);
2195        assert_eq!(row[0], file_anchor("a.rs", "h-new"));
2196        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2197    }
2198
2199    /// Same artifact under a different grain or class is a different
2200    /// identity — it appends rather than replaces (the triple is the merge
2201    /// key, not the artifact alone).
2202    #[test]
2203    fn merge_treats_grain_and_class_as_identity() {
2204        let mut sc = AnchorSidecar::default();
2205        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2206        let mut span = file_anchor("a.rs", "h-span");
2207        span.grain = AnchorGrain::Span;
2208        let mut informed = file_anchor("a.rs", "h-a");
2209        informed.class = AnchorProvenanceClass::InformedBy;
2210        informed.hash = None;
2211        sc.merge("m--e", &[], vec![span, informed]);
2212        assert_eq!(sc.get("m--e").len(), 3);
2213    }
2214
2215    /// Re-sending an entity's full current set is a no-op on the stored
2216    /// bytes, and merging an empty list changes nothing.
2217    #[test]
2218    fn merge_full_resend_and_empty_are_noops() {
2219        let mut sc = AnchorSidecar::default();
2220        sc.set(
2221            "m--e",
2222            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2223        );
2224        let before = sc.to_bytes();
2225        sc.merge(
2226            "m--e",
2227            &[],
2228            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2229        );
2230        assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
2231        sc.merge("m--e", &[], Vec::new());
2232        assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
2233    }
2234
2235    /// A bare-artifact unset removes all of that artifact's anchors and
2236    /// nothing else; a grain/class-narrowed unset removes only the match;
2237    /// a selector matching nothing is a no-op.
2238    #[test]
2239    fn unset_selects_by_artifact_with_optional_narrowing() {
2240        let mut span = file_anchor("a.rs", "h-span");
2241        span.grain = AnchorGrain::Span;
2242        let mut sc = AnchorSidecar::default();
2243        sc.set(
2244            "m--e",
2245            vec![
2246                file_anchor("a.rs", "h-a"),
2247                span.clone(),
2248                file_anchor("b.rs", "h-b"),
2249            ],
2250        );
2251
2252        // Narrowed: only the span-grain anchor on a.rs goes.
2253        let narrowed = AnchorUnset {
2254            artifact: "a.rs".into(),
2255            grain: Some(AnchorGrain::Span),
2256            class: None,
2257        };
2258        sc.merge("m--e", &[narrowed], Vec::new());
2259        assert_eq!(
2260            sc.get("m--e"),
2261            &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
2262        );
2263
2264        // Nonexistent target: idempotent no-op.
2265        let missing = AnchorUnset {
2266            artifact: "never-there.rs".into(),
2267            grain: None,
2268            class: None,
2269        };
2270        sc.merge("m--e", &[missing], Vec::new());
2271        assert_eq!(sc.get("m--e").len(), 2);
2272
2273        // Bare artifact: everything on a.rs goes, b.rs untouched.
2274        let bare = AnchorUnset {
2275            artifact: "a.rs".into(),
2276            grain: None,
2277            class: None,
2278        };
2279        sc.merge("m--e", &[bare], Vec::new());
2280        assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
2281    }
2282
2283    /// Unset applies before merge in the same call: unsetting an artifact
2284    /// and writing a new anchor on it lands the new anchor (full-replace
2285    /// stays expressible in one call).
2286    #[test]
2287    fn unset_applies_before_merge() {
2288        let mut span = file_anchor("a.rs", "h-span");
2289        span.grain = AnchorGrain::Span;
2290        let mut sc = AnchorSidecar::default();
2291        sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
2292        let bare = AnchorUnset {
2293            artifact: "a.rs".into(),
2294            grain: None,
2295            class: None,
2296        };
2297        sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
2298        assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
2299    }
2300
2301    /// A row emptied by unsets prunes its key — the sidecar never keeps
2302    /// empty rows.
2303    #[test]
2304    fn merge_prunes_row_emptied_by_unset() {
2305        let mut sc = AnchorSidecar::default();
2306        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2307        let bare = AnchorUnset {
2308            artifact: "a.rs".into(),
2309            grain: None,
2310            class: None,
2311        };
2312        sc.merge("m--e", &[bare], Vec::new());
2313        assert!(sc.is_empty());
2314        assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
2315    }
2316
2317    /// The unset validator: artifact required; grain/class, when supplied,
2318    /// must be known wire strings; absent narrowing means "any".
2319    #[test]
2320    fn unset_input_validates_typed() {
2321        let ok = AnchorUnsetInput {
2322            artifact: Some("  a.rs  ".into()),
2323            grain: Some("span".into()),
2324            class: None,
2325        }
2326        .validate()
2327        .unwrap();
2328        assert_eq!(ok.artifact, "a.rs");
2329        assert_eq!(ok.grain, Some(AnchorGrain::Span));
2330        assert_eq!(ok.class, None);
2331
2332        let missing = AnchorUnsetInput::default().validate().unwrap_err();
2333        assert!(matches!(missing, AnchorValidationError::MissingArtifact));
2334        assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
2335
2336        let bad_grain = AnchorUnsetInput {
2337            artifact: Some("a.rs".into()),
2338            grain: Some("paragraph".into()),
2339            class: None,
2340        }
2341        .validate()
2342        .unwrap_err();
2343        assert!(matches!(
2344            bad_grain,
2345            AnchorValidationError::UnknownGrain { .. }
2346        ));
2347
2348        let bad_class = AnchorUnsetInput {
2349            artifact: Some("a.rs".into()),
2350            grain: None,
2351            class: Some("guessed".into()),
2352        }
2353        .validate()
2354        .unwrap_err();
2355        assert!(matches!(
2356            bad_class,
2357            AnchorValidationError::UnknownClass { .. }
2358        ));
2359    }
2360
2361    #[test]
2362    fn sidecar_rename_leaves_zero_rows_under_old_id() {
2363        let mut sc = AnchorSidecar::default();
2364        sc.set(
2365            "specs--old",
2366            vec![anchor(
2367                AnchorProvenanceClass::Anchored,
2368                Some("h"),
2369                AnchorHashStability::Stable,
2370            )],
2371        );
2372        sc.rename("specs--old", "specs--new");
2373        assert!(sc.get("specs--old").is_empty());
2374        assert_eq!(sc.get("specs--new").len(), 1);
2375    }
2376
2377    #[test]
2378    fn sidecar_remove_drops_entity_anchors() {
2379        let mut sc = AnchorSidecar::default();
2380        sc.set(
2381            "specs--gone",
2382            vec![anchor(
2383                AnchorProvenanceClass::Anchored,
2384                Some("h"),
2385                AnchorHashStability::Stable,
2386            )],
2387        );
2388        sc.remove("specs--gone");
2389        assert!(sc.get("specs--gone").is_empty());
2390        // Idempotent.
2391        sc.remove("specs--gone");
2392    }
2393
2394    #[test]
2395    fn empty_bytes_parse_as_empty_sidecar() {
2396        assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
2397        assert!(AnchorSidecar::from_bytes(b"  \n ").unwrap().is_empty());
2398    }
2399
2400    #[test]
2401    fn anchor_json_shape_omits_empty_optionals() {
2402        let a = anchor(
2403            AnchorProvenanceClass::Anchored,
2404            Some("h1"),
2405            AnchorHashStability::Stable,
2406        );
2407        let v = serde_json::to_value(&a).unwrap();
2408        assert_eq!(v["artifact"], "src/lib.rs");
2409        assert_eq!(v["grain"], "file");
2410        assert_eq!(v["class"], "anchored");
2411        assert_eq!(v["hash"], "h1");
2412        assert_eq!(v["hash_stability"], "stable");
2413        // Absent optionals are skipped, not null.
2414        assert!(v.get("at_version").is_none());
2415        assert!(v.get("derived_from").is_none());
2416        assert!(v.get("binding").is_none());
2417    }
2418
2419    #[test]
2420    fn anchor_version_serialises_tagged() {
2421        let a = Anchor {
2422            at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2423            ..anchor(
2424                AnchorProvenanceClass::Anchored,
2425                Some("h"),
2426                AnchorHashStability::Stable,
2427            )
2428        };
2429        let v = serde_json::to_value(&a).unwrap();
2430        assert_eq!(v["at_version"]["kind"], "commit");
2431        assert_eq!(v["at_version"]["value"], "deadbeef");
2432    }
2433
2434    /// `source` rides validation: a non-empty name is carried, absent
2435    /// stays absent, and present-but-empty refuses `INVALID_ANCHOR`
2436    /// with `field: source` in the recovery detail.
2437    #[test]
2438    fn validate_source_carried_absent_or_refused_when_empty() {
2439        let mut input = AnchorInput {
2440            artifact: Some("src/lib.rs".into()),
2441            grain: Some("file".into()),
2442            class: Some("anchored".into()),
2443            ..Default::default()
2444        };
2445        assert_eq!(
2446            input.validate(None).unwrap().source,
2447            None,
2448            "absent stays absent"
2449        );
2450
2451        input.source = Some("  api-docs  ".into());
2452        assert_eq!(
2453            input.validate(None).unwrap().source.as_deref(),
2454            Some("api-docs"),
2455            "non-empty name is carried (trimmed)"
2456        );
2457
2458        input.source = Some("   ".into());
2459        let err = input.validate(None).unwrap_err();
2460        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2461        assert!(matches!(err, AnchorValidationError::EmptySource));
2462        assert_eq!(
2463            err.detail().get("field"),
2464            Some(&serde_json::json!("source"))
2465        );
2466    }
2467
2468    /// A sidecar written before the `source` field existed loads
2469    /// unchanged (additive, optional — no migration, no version bump),
2470    /// and a sourced anchor round-trips through serde.
2471    #[test]
2472    fn source_is_additive_on_the_persisted_shape() {
2473        let pre_plan = r#"{
2474            "artifact": "src/lib.rs",
2475            "grain": "file",
2476            "class": "anchored",
2477            "hash_stability": "stable"
2478        }"#;
2479        let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2480        assert_eq!(a.source, None, "no backfill, no default");
2481
2482        let sourced = Anchor {
2483            source: Some("api-docs".into()),
2484            ..a
2485        };
2486        let json = serde_json::to_string(&sourced).unwrap();
2487        let back: Anchor = serde_json::from_str(&json).unwrap();
2488        assert_eq!(back.source.as_deref(), Some("api-docs"));
2489    }
2490}