Skip to main content

memstead_base/
anchor.rs

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