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. Version 2 added the per-row
62/// `last_observed` record; version 1 files load unchanged (the field is
63/// absent) and are rewritten as version 2 on the next sidecar write.
64pub const ANCHOR_SIDECAR_VERSION: u32 = 2;
65
66/// Every sidecar version this engine reads. Anything else refuses typed:
67/// a document written by a later engine is not parsed optimistically.
68pub const ANCHOR_SIDECAR_VERSIONS_READ: &[u32] = &[1, 2];
69
70/// Stable typed error code returned when an `anchors[]` element is
71/// malformed. Mirrors the engine's other typed-envelope codes; the whole
72/// mutation refuses and the entity is not written.
73pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
74
75/// The pinned sentinel a publish-time redaction writes into every
76/// artifact reference (`artifact`, `derived_from` entries). A fixed,
77/// visibly-artificial form rather than an empty string: the anchor entry
78/// stays readable (class, counts, `at_version`, hash — the trust
79/// metadata), while the reference discloses nothing — and an empty
80/// reference stays what it always was, malformed
81/// ([`AnchorSidecar::validate_artifact_references`]).
82pub const REDACTED_ARTIFACT_SENTINEL: &str = "[redacted]";
83
84// ---------------------------------------------------------------------------
85// Provenance class
86// ---------------------------------------------------------------------------
87
88/// The epistemic standing of an anchor — how the entity relates to the
89/// artifact it references.
90///
91/// - [`Anchored`](Self::Anchored) — the entity directly reflects specific
92///   artifact content (carries hash semantics).
93/// - [`Derived`](Self::Derived) — the entity was computed/synthesised from
94///   one or more input artifacts (carries hash semantics; lists inputs).
95/// - [`Authored`](Self::Authored) — a human/agent authored the entity with
96///   the artifact in view (no hash semantics; excluded from drift
97///   adjudication).
98/// - [`InformedBy`](Self::InformedBy) — the artifact informed the entity
99///   without a content-fidelity claim (no hash semantics; excluded from
100///   drift adjudication).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum AnchorProvenanceClass {
104    Anchored,
105    Derived,
106    Authored,
107    InformedBy,
108}
109
110impl AnchorProvenanceClass {
111    /// Every wire string, in declaration order — the allowed set a
112    /// refusal echoes for recovery.
113    pub const WIRE_VALUES: &'static [&'static str] =
114        &["anchored", "derived", "authored", "informed-by"];
115
116    /// Stable wire form.
117    pub fn as_wire(&self) -> &'static str {
118        match self {
119            AnchorProvenanceClass::Anchored => "anchored",
120            AnchorProvenanceClass::Derived => "derived",
121            AnchorProvenanceClass::Authored => "authored",
122            AnchorProvenanceClass::InformedBy => "informed-by",
123        }
124    }
125
126    /// Inverse of [`Self::as_wire`]; `None` for an unknown string so the
127    /// validator can refuse it typed rather than misclassify.
128    pub fn from_wire(s: &str) -> Option<Self> {
129        match s {
130            "anchored" => Some(AnchorProvenanceClass::Anchored),
131            "derived" => Some(AnchorProvenanceClass::Derived),
132            "authored" => Some(AnchorProvenanceClass::Authored),
133            "informed-by" => Some(AnchorProvenanceClass::InformedBy),
134            _ => None,
135        }
136    }
137
138    /// Whether this class carries hash semantics. `anchored` and
139    /// `derived` assert content fidelity and participate in hash-drift
140    /// adjudication; `authored` and `informed-by` do not — a content
141    /// change under them produces no drift state, and supplying a hash on
142    /// them is a validation refusal.
143    pub fn is_hash_bearing(&self) -> bool {
144        matches!(
145            self,
146            AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
147        )
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Grain
153// ---------------------------------------------------------------------------
154
155/// The granularity of the artifact reference an anchor carries.
156///
157/// `span` / `file` / `tree` select within a path-shaped namespace; `url`
158/// selects a web resource; `entity` selects another mem's entity. The
159/// medium-capability matrix ([`crate::binding::medium_capabilities`])
160/// decides which grains a given medium's namespace can support — a
161/// mismatch (e.g. `span` on a `url`-namespace medium) refuses typed at
162/// validation.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub enum AnchorGrain {
166    Span,
167    File,
168    Tree,
169    Url,
170    Entity,
171}
172
173impl AnchorGrain {
174    /// Every wire string, in declaration order.
175    pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
176
177    /// Stable wire form.
178    pub fn as_wire(&self) -> &'static str {
179        match self {
180            AnchorGrain::Span => "span",
181            AnchorGrain::File => "file",
182            AnchorGrain::Tree => "tree",
183            AnchorGrain::Url => "url",
184            AnchorGrain::Entity => "entity",
185        }
186    }
187
188    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
189    pub fn from_wire(s: &str) -> Option<Self> {
190        match s {
191            "span" => Some(AnchorGrain::Span),
192            "file" => Some(AnchorGrain::File),
193            "tree" => Some(AnchorGrain::Tree),
194            "url" => Some(AnchorGrain::Url),
195            "entity" => Some(AnchorGrain::Entity),
196            _ => None,
197        }
198    }
199
200    /// Whether this grain can be expressed in the medium's declared anchor
201    /// namespace (the `anchor_namespace` string from the E2 capability
202    /// matrix: `path` / `path+commit` / `entity` / `url`).
203    ///
204    /// - `span` / `file` / `tree` require a path-shaped namespace
205    ///   (`path` or `path+commit`);
206    /// - `url` is admitted beside every namespace: a URL is an absolute
207    ///   reference that never enters a path or entity namespace, so it
208    ///   collides with nothing there — and the engine never observes it
209    ///   itself, so no medium capability is claimed by admitting it;
210    /// - `entity` requires the `entity` namespace.
211    pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
212        let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
213        match self {
214            AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
215            AnchorGrain::Url => true,
216            AnchorGrain::Entity => anchor_namespace == "entity",
217        }
218    }
219
220    /// Whether this grain selects within a path-shaped namespace.
221    pub fn is_path_shaped(&self) -> bool {
222        matches!(
223            self,
224            AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree
225        )
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Hash stability
231// ---------------------------------------------------------------------------
232
233/// The medium's declared hash stability — whether a change in the
234/// prepared-content hash is a reliable drift signal.
235///
236/// A `stable` medium's hash break resolves [`AnchorState::Drifted`]; an
237/// `unstable` medium's hash break resolves [`AnchorState::Recheck`]
238/// (the hash may have moved for reasons unrelated to the entity's claim,
239/// so the engine flags it for re-examination rather than asserting drift).
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "lowercase")]
242pub enum AnchorHashStability {
243    Stable,
244    Unstable,
245}
246
247impl AnchorHashStability {
248    /// Every wire string.
249    pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
250
251    /// Stable wire form.
252    pub fn as_wire(&self) -> &'static str {
253        match self {
254            AnchorHashStability::Stable => "stable",
255            AnchorHashStability::Unstable => "unstable",
256        }
257    }
258
259    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
260    pub fn from_wire(s: &str) -> Option<Self> {
261        match s {
262            "stable" => Some(AnchorHashStability::Stable),
263            "unstable" => Some(AnchorHashStability::Unstable),
264            _ => None,
265        }
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Medium-typed version
271// ---------------------------------------------------------------------------
272
273/// A medium-typed pinned version the anchor was recorded against.
274///
275/// Which variant applies follows from the medium's namespace: a git /
276/// `path+commit` medium pins a [`Commit`](Self::Commit); a graph / `entity`
277/// medium pins a [`Snapshot`](Self::Snapshot) token; a web / `url` medium
278/// pins an [`Etag`](Self::Etag). A plain `path` medium (mtime change
279/// signal, no retrievable version) records **absent** — represented as
280/// `None` on [`Anchor::at_version`], never a variant here.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
283pub enum AnchorVersion {
284    /// A git commit id (`path+commit` / git namespace).
285    Commit(String),
286    /// A graph snapshot token (`entity` namespace).
287    Snapshot(String),
288    /// A web ETag (`url` namespace).
289    Etag(String),
290}
291
292// ---------------------------------------------------------------------------
293// Anchor
294// ---------------------------------------------------------------------------
295
296/// One durable anchor record: an entity's provenance tie to a single
297/// source artifact.
298///
299/// This is the persisted + read shape. Malformed wire input is refused
300/// upstream via [`AnchorInput::validate`], which produces this strict type
301/// only when every rule holds.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct Anchor {
304    /// Artifact reference in the medium's own namespace — a repo-relative
305    /// path, a `path@commit`, a URL, or an entity id, interpreted per
306    /// [`Self::grain`] and the medium.
307    pub artifact: String,
308    /// The granularity of [`Self::artifact`].
309    pub grain: AnchorGrain,
310    /// The anchor's epistemic standing.
311    pub class: AnchorProvenanceClass,
312    /// The medium-typed pinned version, or `None` when the medium has no
313    /// retrievable version (plain `path` / mtime).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub at_version: Option<AnchorVersion>,
316    /// Content hash over the **prepared** artifact form (never raw bytes),
317    /// present only when [`Self::class`] carries hash semantics. `None`
318    /// for `authored` / `informed-by`.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub hash: Option<String>,
321    /// The medium's declared hash stability — governs whether a hash break
322    /// resolves `drifted` or `recheck`.
323    pub hash_stability: AnchorHashStability,
324    /// For a `derived` class: the input artifact refs the entity was
325    /// derived from. Empty for every other class.
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub derived_from: Vec<String>,
328    /// `hash(D)` of the binding that produced this anchor (E2), when a
329    /// binding produced it. `None` for a manually-authored anchor with no
330    /// producing binding.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub binding: Option<String>,
333    /// The NAME of the source (as declared in the producing binding's
334    /// `sources[]`) that produced this anchor — so a discovery run can
335    /// be measured per entry point. Optional and additive: pre-existing
336    /// sidecars load unchanged and are never backfilled (a guessed
337    /// provenance is worse than an absent one). Validated against the
338    /// producing binding's declared names only when [`Self::binding`]
339    /// still resolves in the workspace.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub source: Option<String>,
342    /// A `span`-grain row whose locator could NOT be checked against the
343    /// artifact at write time (consistency-sweep 03/03). The write path
344    /// deliberately reads no source content, so the check is possible only
345    /// where the caller supplied `content`; elsewhere the anchor is accepted
346    /// and this records that its span is unverified, rather than letting a
347    /// later surface report it as adjudicated. Never set on a non-`span`
348    /// grain. Serialized only when true, so an existing sidecar is unchanged.
349    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
350    pub span_unvalidated: bool,
351    /// Who established this row's [`Self::hash`] baseline. `None` on every
352    /// row written before the field existed, which is honest: the baseline's
353    /// origin was not recorded then and guessing it would be worse than
354    /// admitting it. Set at write and at backfill from then on, so a reader
355    /// can tell an author-pinned baseline from an engine-inferred one.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub hash_source: Option<AnchorHashSource>,
358    /// The most recent observation recorded for this row (sidecar version
359    /// 2): when it was made, the prepared-content hash it saw, and the state
360    /// it resolved. Written for grains the engine cannot observe itself —
361    /// a `url` row adjudicated from an observer-supplied observation — so
362    /// the row can age visibly (`unobserved for N days`) instead of resting
363    /// in `unobserved` forever. Path and entity rows are observed live on
364    /// every pass and carry none. Absent on every version-1 row.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub last_observed: Option<AnchorObservation>,
367}
368
369/// One recorded observation of an anchor's artifact (the `last_observed`
370/// record of a sidecar row).
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct AnchorObservation {
373    /// When the observation was made — second-granularity ISO-8601 UTC
374    /// (`YYYY-MM-DDTHH:MM:SSZ`), as supplied by the observer or stamped by
375    /// the engine at recording time.
376    pub at: String,
377    /// The prepared-content hash the observation saw; `None` when the
378    /// observer reported the artifact absent.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub hash: Option<String>,
381    /// The state the row resolved to against that observation.
382    pub state: AnchorState,
383}
384
385/// Who established an anchor's hash baseline (consistency-sweep 03/03,
386/// criterion 8). A baseline that resets with no trace makes drift
387/// unfalsifiable, so the origin is recorded rather than inferred.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(rename_all = "kebab-case")]
390pub enum AnchorHashSource {
391    /// The writer supplied the hash, or the content the engine hashed.
392    Author,
393    /// A completed verify filled a hash-less row from what it observed.
394    Backfill,
395}
396
397impl AnchorHashSource {
398    pub fn as_wire(self) -> &'static str {
399        match self {
400            AnchorHashSource::Author => "author",
401            AnchorHashSource::Backfill => "backfill",
402        }
403    }
404}
405
406// ---------------------------------------------------------------------------
407// Span locators
408// ---------------------------------------------------------------------------
409
410/// What a `span` anchor's locator (everything after the first `#`) selects.
411///
412/// Two forms are legal and the distinction is not cosmetic. A LINE RANGE is
413/// checkable against content the write path already holds; a UNIT KEY is a
414/// delivery preparation's own key (`dated-entries` writes
415/// `<path>#<iso-stamp>`), whose validity only that preparation can judge, and
416/// which the existing unit-absent refusal already covers where content is
417/// supplied. Anything the engine can check, it checks; anything it cannot, it
418/// records as unchecked.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum SpanLocator<'a> {
421    /// `L<start>` or `L<start>-L<end>`, 1-based and inclusive.
422    Lines { start: usize, end: usize },
423    /// A preparation's delivery-unit key, opaque here.
424    Unit(&'a str),
425}
426
427/// Parse a `span` artifact reference's locator, or say why it cannot be one.
428/// `Ok(None)` means the reference carries no locator at all.
429///
430/// **No locator is not a refusal**, and that is a deliberate line. A
431/// `span`-grain reference naming a bare path addresses its whole file, which
432/// is what a span's hash covers anyway with no preparation declared, and such
433/// anchors are written today. Refusing them would be a new wall across a
434/// working flow, which the plan's own criterion 4 forbids.
435///
436/// The refusals are the shapes that can never address anything: an EMPTY
437/// locator (`path#`, which announces a span and then names none), and a
438/// locator that announces itself as a line range by its `L` prefix and then
439/// contradicts itself (no digits, a zero line, an end before its start). A
440/// locator that does not look like a line range is a unit key and is accepted
441/// here, because this function cannot know a preparation's key grammar and
442/// refusing what it cannot judge would break every `dated-entries` anchor.
443pub fn parse_span_locator(artifact: &str) -> Result<Option<SpanLocator<'_>>, &'static str> {
444    let locator = match artifact.split_once('#') {
445        None => return Ok(None),
446        Some((_, loc)) if loc.trim().is_empty() => {
447            return Err("the span locator after `#` is empty");
448        }
449        Some((_, loc)) => loc,
450    };
451    // Only an `L`-prefixed locator claims to be a line range. Everything else
452    // is a unit key and is not this function's to judge.
453    let looks_like_lines = locator.starts_with('L')
454        && locator[1..]
455            .chars()
456            .next()
457            .is_some_and(|c| c.is_ascii_digit());
458    if !looks_like_lines {
459        return Ok(Some(SpanLocator::Unit(locator)));
460    }
461    let (start_raw, end_raw) = match locator.split_once('-') {
462        None => (locator, locator),
463        Some((a, b)) => (a, b),
464    };
465    let num = |part: &str| -> Option<usize> {
466        part.strip_prefix('L')
467            .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
468            .and_then(|d| d.parse::<usize>().ok())
469    };
470    let (Some(start), Some(end)) = (num(start_raw), num(end_raw)) else {
471        return Err("a line-range span locator must read `L<start>` or `L<start>-L<end>`");
472    };
473    if start == 0 {
474        return Err("line numbers are 1-based, so `L0` addresses nothing");
475    }
476    if end < start {
477        return Err("a line-range span locator ends before it starts");
478    }
479    Ok(Some(SpanLocator::Lines { start, end }))
480}
481
482// ---------------------------------------------------------------------------
483// Validation
484// ---------------------------------------------------------------------------
485
486/// A permissive wire-shaped anchor element as it arrives on a mutation's
487/// `anchors[]` parameter. All fields are optional / string-typed so an
488/// unknown class or grain surfaces as a typed [`AnchorValidationError`]
489/// with recovery detail rather than an opaque serde failure. Call
490/// [`Self::validate`] to obtain a strict [`Anchor`].
491#[derive(Debug, Clone, Default, Serialize, Deserialize)]
492pub struct AnchorInput {
493    #[serde(default)]
494    pub artifact: Option<String>,
495    #[serde(default)]
496    pub grain: Option<String>,
497    #[serde(default)]
498    pub class: Option<String>,
499    #[serde(default)]
500    pub at_version: Option<AnchorVersion>,
501    #[serde(default)]
502    pub hash: Option<String>,
503    /// The observed artifact CONTENT (UTF-8 text), for the engine to compute
504    /// `hash` from through its preparation registry
505    /// ([`crate::preparation::supplied_content_hash`]) — the write-time
506    /// observation for a grain the engine cannot observe itself: a `url`
507    /// anchor, because the engine never fetches. Accepted for the `span` /
508    /// `file` / `url` grains; mutually exclusive with `hash`; refused on a
509    /// non-hash class and on the `entity` / `tree` grains, whose prepared
510    /// form is never computed from supplied bytes.
511    #[serde(default)]
512    pub content: Option<String>,
513    #[serde(default)]
514    pub hash_stability: Option<String>,
515    #[serde(default)]
516    pub derived_from: Option<Vec<String>>,
517    #[serde(default)]
518    pub binding: Option<String>,
519    #[serde(default)]
520    pub source: Option<String>,
521}
522
523/// A permissive wire-shaped `anchors_unset[]` element — an explicit
524/// removal selector on the update surface. Each entry names an `artifact`
525/// and may narrow by `grain` and/or `class`; a bare artifact selects every
526/// anchor on it. String-typed like [`AnchorInput`] so an unknown grain or
527/// class refuses typed (`INVALID_ANCHOR`) rather than silently selecting
528/// nothing forever. Call [`Self::validate`] to obtain a strict
529/// [`AnchorUnset`].
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
531pub struct AnchorUnsetInput {
532    #[serde(default)]
533    pub artifact: Option<String>,
534    #[serde(default)]
535    pub grain: Option<String>,
536    #[serde(default)]
537    pub class: Option<String>,
538}
539
540impl AnchorUnsetInput {
541    /// Validate this wire element into a strict [`AnchorUnset`], or refuse
542    /// typed. Rules: artifact present and non-empty; grain / class, when
543    /// supplied, must be known wire strings (absent means "any").
544    pub fn validate(&self) -> Result<AnchorUnset, AnchorValidationError> {
545        let artifact = self
546            .artifact
547            .as_deref()
548            .map(str::trim)
549            .filter(|s| !s.is_empty())
550            .map(str::to_string)
551            .ok_or(AnchorValidationError::MissingArtifact)?;
552        let grain = match self.grain.as_deref() {
553            None => None,
554            Some(s) => Some(AnchorGrain::from_wire(s).ok_or_else(|| {
555                AnchorValidationError::UnknownGrain {
556                    got: Some(s.to_string()),
557                    allowed: AnchorGrain::WIRE_VALUES,
558                }
559            })?),
560        };
561        let class = match self.class.as_deref() {
562            None => None,
563            Some(s) => Some(AnchorProvenanceClass::from_wire(s).ok_or_else(|| {
564                AnchorValidationError::UnknownClass {
565                    got: Some(s.to_string()),
566                    allowed: AnchorProvenanceClass::WIRE_VALUES,
567                }
568            })?),
569        };
570        Ok(AnchorUnset {
571            artifact,
572            grain,
573            class,
574        })
575    }
576}
577
578/// A validated explicit-removal selector: which of an entity's anchors an
579/// update's `anchors_unset[]` entry removes. Selection is by artifact,
580/// optionally narrowed by grain and/or class; a selector matching nothing
581/// is a no-op (removal is idempotent — its job in recovery flows is "make
582/// sure this is gone").
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct AnchorUnset {
585    /// Artifact reference to remove anchors from, exactly as stored.
586    pub artifact: String,
587    /// When present, only anchors of this grain are removed.
588    pub grain: Option<AnchorGrain>,
589    /// When present, only anchors of this class are removed.
590    pub class: Option<AnchorProvenanceClass>,
591}
592
593impl AnchorUnset {
594    /// Whether this selector removes `anchor`.
595    pub fn matches(&self, anchor: &Anchor) -> bool {
596        anchor.artifact == self.artifact
597            && self.grain.is_none_or(|g| anchor.grain == g)
598            && self.class.is_none_or(|c| anchor.class == c)
599    }
600}
601
602/// A typed `INVALID_ANCHOR` refusal. The whole mutation refuses and the
603/// entity is not written; [`Self::detail`] carries the recovery payload
604/// (offending value + allowed set) the agent fixes from.
605#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
606pub enum AnchorValidationError {
607    /// Provenance class is absent or not one of the allowed wire strings.
608    #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
609    UnknownClass {
610        got: Option<String>,
611        allowed: &'static [&'static str],
612    },
613    /// Grain is absent or not one of the allowed wire strings.
614    #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
615    UnknownGrain {
616        got: Option<String>,
617        allowed: &'static [&'static str],
618    },
619    /// Hash stability, when supplied, is not an allowed wire string.
620    #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
621    UnknownHashStability {
622        got: String,
623        allowed: &'static [&'static str],
624    },
625    /// The artifact reference is missing or empty.
626    #[error("anchor is missing its artifact reference")]
627    MissingArtifact,
628    /// A content hash (or content to hash) was supplied on a class that
629    /// carries no hash semantics (`authored` / `informed-by`).
630    #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
631    HashOnNonHashClass { class: &'static str },
632    /// Both `hash` and `content` were supplied — the engine computes the
633    /// hash from content, so a supplied hash beside it is ambiguous.
634    #[error(
635        "anchor supplies both `hash` and `content`; supply one — the engine computes the hash from `content`"
636    )]
637    ContentAndHash,
638    /// `content` was supplied for a grain whose prepared form is never
639    /// computed from supplied bytes: `entity` (computed from the live graph)
640    /// or `tree` (whose prepared form, under a code map, is enumerated by
641    /// the engine).
642    #[error(
643        "anchor grain '{grain}' does not accept `content`: its prepared form is not computed \
644         from supplied bytes (accepted for span / file / url)"
645    )]
646    ContentNotAcceptedForGrain { grain: &'static str },
647    /// `content` was supplied for a `<path>#<key>` unit under a delivery
648    /// preparation, but the content yields no unit with that key.
649    #[error(
650        "anchor artifact {artifact:?} names a delivery unit the supplied `content` does not \
651         yield; supply the whole file's content, or address a unit it contains"
652    )]
653    UnitAbsentFromContent { artifact: String },
654    /// A `span`-grain anchor whose locator is missing, empty, or announces a
655    /// line range and then contradicts itself. Refused at write: such a row
656    /// can never address anything, and accepting it produces an anchor that
657    /// is unadjudicable from birth.
658    #[error("anchor artifact {artifact:?} is not a usable span reference: {reason}")]
659    SpanLocatorUnusable {
660        artifact: String,
661        reason: &'static str,
662    },
663    /// A `span`-grain anchor whose line range lies outside the content the
664    /// caller supplied. Only fires where content is in hand — the write path
665    /// reads no source, and where it cannot check, the row records that
666    /// instead (`span_unvalidated`).
667    #[error(
668        "anchor artifact {artifact:?} names lines the supplied `content` does not have \
669         (it has {lines} line(s)); address a range the artifact contains"
670    )]
671    SpanOutsideContent { artifact: String, lines: usize },
672    /// One payload named the same `(artifact, grain, class)` triple twice.
673    /// That triple is the sidecar's merge identity, so the later occurrence
674    /// silently replaced the earlier one and the caller was never told an
675    /// anchor it wrote had gone missing. A LATER call replacing the stored
676    /// row is unaffected: the unit of this refusal is one payload.
677    #[error(
678        "the anchors payload names {artifact:?} at grain `{grain}` and class `{class}` more \
679         than once; that triple is one row, so the repeats would silently collapse to the \
680         last one: send it once, or vary the grain or class"
681    )]
682    DuplicateAnchorTriple {
683        artifact: String,
684        grain: &'static str,
685        class: &'static str,
686    },
687    /// A `source` was supplied but is empty after trimming — a source
688    /// name, when present, must be one of the producing binding's
689    /// declared names, and an empty string can never be one.
690    #[error("anchor `source`, when present, must be a non-empty source name")]
691    EmptySource,
692    /// The anchor's `source` is not among the sources declared by its
693    /// own (resolvable) producing binding. Carries the declared names
694    /// as the recovery payload. Only fires when the `binding` hash
695    /// still resolves in this workspace — an orphaned or since-edited
696    /// binding accepts any non-empty name, deliberately: a legacy
697    /// anchor whose binding was renamed keeps writing as long as its
698    /// artifact reference is alive under the workspace-relative
699    /// fallback (`mem_commands::source_dialect_anchors_join_fallback_collide_and_refuse`).
700    #[error(
701        "anchor `source` {got:?} is not declared by the anchor's producing binding; \
702         declared sources: {}",
703        declared.join(", ")
704    )]
705    SourceNotDeclared { got: String, declared: Vec<String> },
706    /// A path-grain artifact reference that resolves under NO candidate
707    /// join — neither source-relative (joined onto the declaring source's
708    /// pointer, decision 26) nor workspace-relative. Refused at write time
709    /// so the mutation never stores a silently dead (orphaned-at-birth)
710    /// reference; the payload names every candidate tried so the agent can
711    /// fix the dialect.
712    #[error(
713        "anchor artifact {artifact:?} resolves under no candidate path (tried: {}); artifact \
714         paths are source-relative (joined onto the source's pointer) or workspace-relative — \
715         write the path exactly as the brief lists it",
716        candidates.join(", ")
717    )]
718    ArtifactUnresolvable {
719        artifact: String,
720        candidates: Vec<String>,
721    },
722    /// The grain cannot be expressed in the medium's anchor namespace
723    /// (per the E2 capability matrix), e.g. `span` on a non-path medium.
724    #[error(
725        "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
726         '{anchor_namespace}' namespace does not admit that grain"
727    )]
728    GrainNamespaceUnsupported {
729        grain: &'static str,
730        medium_type: String,
731        anchor_namespace: &'static str,
732    },
733    /// A path-shaped grain (`span` / `file` / `tree`) names a URL. A URL
734    /// never enters a path namespace: the web resource is addressed by the
735    /// `url` grain, and a page coordinate inside it is not a path span.
736    #[error(
737        "anchor grain '{grain}' selects within a path namespace, but its artifact '{artifact}'          is a URL — a URL never enters a path namespace; use `grain: url` for the resource"
738    )]
739    PathGrainOnUrlArtifact {
740        grain: &'static str,
741        artifact: String,
742    },
743}
744
745/// Whether an artifact string is URL-shaped (`<scheme>://…`).
746pub fn looks_like_url(artifact: &str) -> bool {
747    let Some((scheme, rest)) = artifact.split_once("://") else {
748        return false;
749    };
750    !rest.is_empty()
751        && scheme
752            .chars()
753            .next()
754            .is_some_and(|c| c.is_ascii_alphabetic())
755        && scheme
756            .chars()
757            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
758}
759
760impl AnchorValidationError {
761    /// The stable typed code — always [`INVALID_ANCHOR_CODE`].
762    pub fn code(&self) -> &'static str {
763        INVALID_ANCHOR_CODE
764    }
765
766    /// Structured recovery detail for the typed envelope: the offending
767    /// field, its bad value, and the allowed set where one applies.
768    pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
769        let mut d = BTreeMap::new();
770        match self {
771            AnchorValidationError::UnknownClass { got, allowed } => {
772                d.insert("field".into(), "class".into());
773                d.insert("got".into(), serde_json::json!(got));
774                d.insert("allowed".into(), serde_json::json!(allowed));
775            }
776            AnchorValidationError::UnknownGrain { got, allowed } => {
777                d.insert("field".into(), "grain".into());
778                d.insert("got".into(), serde_json::json!(got));
779                d.insert("allowed".into(), serde_json::json!(allowed));
780            }
781            AnchorValidationError::UnknownHashStability { got, allowed } => {
782                d.insert("field".into(), "hash_stability".into());
783                d.insert("got".into(), serde_json::json!(got));
784                d.insert("allowed".into(), serde_json::json!(allowed));
785            }
786            AnchorValidationError::MissingArtifact => {
787                d.insert("field".into(), "artifact".into());
788            }
789            AnchorValidationError::EmptySource => {
790                d.insert("field".into(), "source".into());
791            }
792            AnchorValidationError::SourceNotDeclared { got, declared } => {
793                d.insert("field".into(), "source".into());
794                d.insert("got".into(), serde_json::json!(got));
795                d.insert("declared".into(), serde_json::json!(declared));
796            }
797            AnchorValidationError::HashOnNonHashClass { class } => {
798                d.insert("field".into(), "hash".into());
799                d.insert("class".into(), serde_json::json!(class));
800            }
801            AnchorValidationError::ContentAndHash => {
802                d.insert("field".into(), "content".into());
803                d.insert(
804                    "expected".into(),
805                    serde_json::json!("either `hash` or `content`, never both"),
806                );
807            }
808            AnchorValidationError::ContentNotAcceptedForGrain { grain } => {
809                d.insert("field".into(), "content".into());
810                d.insert("grain".into(), serde_json::json!(grain));
811                d.insert(
812                    "accepted_grains".into(),
813                    serde_json::json!(["span", "file", "url"]),
814                );
815            }
816            AnchorValidationError::UnitAbsentFromContent { artifact } => {
817                d.insert("field".into(), "content".into());
818                d.insert("got".into(), serde_json::json!(artifact));
819            }
820            AnchorValidationError::SpanLocatorUnusable { artifact, reason } => {
821                d.insert("field".into(), "artifact".into());
822                d.insert("got".into(), serde_json::json!(artifact));
823                d.insert("expected".into(), serde_json::json!(reason));
824            }
825            AnchorValidationError::SpanOutsideContent { artifact, lines } => {
826                d.insert("field".into(), "artifact".into());
827                d.insert("got".into(), serde_json::json!(artifact));
828                d.insert("content_lines".into(), serde_json::json!(lines));
829            }
830            AnchorValidationError::DuplicateAnchorTriple {
831                artifact,
832                grain,
833                class,
834            } => {
835                d.insert("field".into(), "anchors".into());
836                d.insert(
837                    "got".into(),
838                    serde_json::json!({ "artifact": artifact, "grain": grain, "class": class }),
839                );
840                d.insert(
841                    "expected".into(),
842                    serde_json::json!(
843                        "each (artifact, grain, class) triple at most once per payload"
844                    ),
845                );
846            }
847            AnchorValidationError::ArtifactUnresolvable {
848                artifact,
849                candidates,
850            } => {
851                d.insert("field".into(), "artifact".into());
852                d.insert("got".into(), serde_json::json!(artifact));
853                d.insert("candidates_tried".into(), serde_json::json!(candidates));
854                d.insert(
855                    "expected".into(),
856                    serde_json::json!(
857                        "a source-relative path (joined onto the source's pointer) or a \
858                         workspace-relative path that resolves to an existing artifact"
859                    ),
860                );
861            }
862            AnchorValidationError::GrainNamespaceUnsupported {
863                grain,
864                medium_type,
865                anchor_namespace,
866            } => {
867                d.insert("field".into(), "grain".into());
868                d.insert("grain".into(), serde_json::json!(grain));
869                d.insert("medium_type".into(), serde_json::json!(medium_type));
870                d.insert(
871                    "anchor_namespace".into(),
872                    serde_json::json!(anchor_namespace),
873                );
874            }
875            AnchorValidationError::PathGrainOnUrlArtifact { grain, artifact } => {
876                d.insert("field".into(), "grain".into());
877                d.insert("grain".into(), serde_json::json!(grain));
878                d.insert("got".into(), serde_json::json!(artifact));
879                d.insert(
880                    "expected".into(),
881                    serde_json::json!(
882                        "`grain: url` for a web resource — a URL never enters a path namespace"
883                    ),
884                );
885            }
886        }
887        d
888    }
889}
890
891impl AnchorInput {
892    /// Validate this wire element into a strict [`Anchor`], or refuse
893    /// typed.
894    ///
895    /// `medium` — the resolving medium's `(type_name, anchor_namespace)`
896    /// pair, when the mutation resolved one. When `Some`, the grain is
897    /// checked against the namespace (the capability-matrix refusal);
898    /// when `None` (no medium context — a manually-authored anchor), the
899    /// namespace check is skipped and only the vocabulary + hash-semantics
900    /// rules apply.
901    ///
902    /// Rules enforced (each a typed [`AnchorValidationError`]):
903    /// - class present and known;
904    /// - grain present and known;
905    /// - artifact reference present and non-empty;
906    /// - a hash (or content to hash) is supplied only on a hash-bearing
907    ///   class; `content` and `hash` are mutually exclusive; `content` is
908    ///   accepted only for the grains whose prepared form the registry
909    ///   computes from supplied bytes (`span` / `file` / `url`), and then
910    ///   `hash` is the registry's prepared hash of it;
911    /// - hash stability, when supplied, is a known wire string (defaults
912    ///   per grain when absent — `url` unstable, every other grain stable);
913    /// - grain supported by the medium's namespace (when `medium` given).
914    pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
915        let class = match self
916            .class
917            .as_deref()
918            .and_then(AnchorProvenanceClass::from_wire)
919        {
920            Some(c) => c,
921            None => {
922                return Err(AnchorValidationError::UnknownClass {
923                    got: self.class.clone(),
924                    allowed: AnchorProvenanceClass::WIRE_VALUES,
925                });
926            }
927        };
928        let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
929            Some(g) => g,
930            None => {
931                return Err(AnchorValidationError::UnknownGrain {
932                    got: self.grain.clone(),
933                    allowed: AnchorGrain::WIRE_VALUES,
934                });
935            }
936        };
937
938        let artifact = self
939            .artifact
940            .as_deref()
941            .map(str::trim)
942            .filter(|s| !s.is_empty())
943            .map(str::to_string)
944            .ok_or(AnchorValidationError::MissingArtifact)?;
945
946        // Hash stability: default per grain when absent (`url` unstable,
947        // every other grain stable); refuse an unknown supplied value.
948        let hash_stability = match self.hash_stability.as_deref() {
949            None => crate::preparation::default_hash_stability(grain),
950            Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
951                AnchorValidationError::UnknownHashStability {
952                    got: s.to_string(),
953                    allowed: AnchorHashStability::WIRE_VALUES,
954                }
955            })?,
956        };
957
958        // A hash is only meaningful on a hash-bearing class.
959        let hash = self
960            .hash
961            .as_deref()
962            .map(str::trim)
963            .filter(|s| !s.is_empty())
964            .map(str::to_string);
965        if (hash.is_some() || self.content.is_some()) && !class.is_hash_bearing() {
966            return Err(AnchorValidationError::HashOnNonHashClass {
967                class: class.as_wire(),
968            });
969        }
970        // Supplied content: the engine computes the prepared hash through the
971        // preparation registry (touchpoint A at write time) — the one way a
972        // `url` anchor's recorded hash is ever the engine's prepared form.
973        let hash = match self.content.as_deref() {
974            None => hash,
975            Some(_) if hash.is_some() => return Err(AnchorValidationError::ContentAndHash),
976            Some(content) => {
977                match crate::preparation::supplied_content_hash(grain, content.as_bytes()) {
978                    Some(h) => Some(h),
979                    None => {
980                        return Err(AnchorValidationError::ContentNotAcceptedForGrain {
981                            grain: grain.as_wire(),
982                        });
983                    }
984                }
985            }
986        };
987
988        // The span itself (consistency-sweep 03/03). A locator that can never
989        // address anything is refused here, context-free, because no medium
990        // context can rescue it. Where the caller supplied content, a line
991        // range is checked against it; where they did not, the row carries
992        // `span_unvalidated` so no later surface reports it as adjudicated.
993        let mut span_unvalidated = false;
994        if grain == AnchorGrain::Span {
995            let locator = parse_span_locator(&artifact).map_err(|reason| {
996                AnchorValidationError::SpanLocatorUnusable {
997                    artifact: artifact.clone(),
998                    reason,
999                }
1000            })?;
1001            match (locator, self.content.as_deref()) {
1002                (Some(SpanLocator::Lines { end, .. }), Some(content)) => {
1003                    let lines = content.lines().count();
1004                    if end > lines {
1005                        return Err(AnchorValidationError::SpanOutsideContent {
1006                            artifact: artifact.clone(),
1007                            lines,
1008                        });
1009                    }
1010                }
1011                // A unit key with content in hand is the existing
1012                // unit-absent refusal's business, at the seam that knows the
1013                // source's preparation; a check here would have to guess it.
1014                (Some(SpanLocator::Unit(_)), Some(_)) => {}
1015                // No locator addresses the whole artifact, and the existence
1016                // gate already checks that the artifact is there. Nothing is
1017                // left unchecked, so nothing is recorded as unchecked.
1018                (None, _) => {}
1019                (Some(_), None) => span_unvalidated = true,
1020            }
1021        }
1022
1023        // A path-shaped grain never names a URL: the resource is the `url`
1024        // grain's business, whatever medium the mem sits beside.
1025        if grain.is_path_shaped() && looks_like_url(&artifact) {
1026            return Err(AnchorValidationError::PathGrainOnUrlArtifact {
1027                grain: grain.as_wire(),
1028                artifact,
1029            });
1030        }
1031
1032        // Grain must be expressible in the medium's namespace (a `url`
1033        // grain is admitted beside every medium — see
1034        // [`AnchorGrain::supported_by_namespace`]).
1035        if let Some((medium_type, namespace)) = medium
1036            && !grain.supported_by_namespace(namespace)
1037        {
1038            // Resolve the namespace to its `&'static str` so the error
1039            // carries a stable value even though the input came borrowed.
1040            let anchor_namespace = match namespace {
1041                "path" => "path",
1042                "path+commit" => "path+commit",
1043                "entity" => "entity",
1044                "url" => "url",
1045                _ => "path",
1046            };
1047            return Err(AnchorValidationError::GrainNamespaceUnsupported {
1048                grain: grain.as_wire(),
1049                medium_type: medium_type.to_string(),
1050                anchor_namespace,
1051            });
1052        }
1053
1054        // `source`, when present, must be non-empty. (Whether it names a
1055        // source the producing binding actually declares is checked at
1056        // the engine seam, which can resolve the binding hash — this
1057        // context-free validator cannot.)
1058        let source = match self.source.as_deref() {
1059            None => None,
1060            Some(raw) => {
1061                let trimmed = raw.trim();
1062                if trimmed.is_empty() {
1063                    return Err(AnchorValidationError::EmptySource);
1064                }
1065                Some(trimmed.to_string())
1066            }
1067        };
1068
1069        Ok(Anchor {
1070            artifact,
1071            grain,
1072            class,
1073            at_version: self.at_version.clone(),
1074            // A hash present at this point came from the writer, directly or
1075            // as content the engine hashed. The backfill stamps its own.
1076            hash_source: hash.is_some().then_some(AnchorHashSource::Author),
1077            hash,
1078            hash_stability,
1079            derived_from: self.derived_from.clone().unwrap_or_default(),
1080            binding: self
1081                .binding
1082                .as_deref()
1083                .map(str::trim)
1084                .filter(|s| !s.is_empty())
1085                .map(str::to_string),
1086            source,
1087            span_unvalidated,
1088            last_observed: None,
1089        })
1090    }
1091}
1092
1093// ---------------------------------------------------------------------------
1094// Prepared-content hash
1095// ---------------------------------------------------------------------------
1096
1097/// Compute the **prepared-content hash** of a path-grain artifact's bytes —
1098/// the value [`Anchor::hash`] records and hash-drift adjudication compares.
1099///
1100/// The prepared form is a deliberate, minimal canonicalization that keeps the
1101/// hash stable across meaningless byte noise while preserving every
1102/// content-bearing byte. For UTF-8 text:
1103///
1104/// - a leading BOM (U+FEFF) is stripped;
1105/// - CRLF / lone-CR line endings normalize to LF;
1106/// - trailing newlines are trimmed (final-newline presence is noise).
1107///
1108/// Interior whitespace is untouched — trailing spaces inside a line can be
1109/// content (markdown hard breaks), so only the two classic cross-tool noise
1110/// sources (encoding marks, line-ending convention) and the final-newline
1111/// question are canonicalized. Non-UTF-8 (binary) bytes hash as-is — no text
1112/// canonicalization applies to them.
1113///
1114/// The hash form reuses the house convention — SHA-256, lowercase hex,
1115/// truncated to 16 characters — shared by entity content hashes
1116/// ([`crate::entity::parser::compute_hash`]) and the change-detection digest
1117/// aggregate, so the engine keeps one hash shape rather than growing a
1118/// second normalization.
1119pub fn prepared_content_hash(bytes: &[u8]) -> String {
1120    use sha2::{Digest as _, Sha256};
1121    let digest = match std::str::from_utf8(bytes) {
1122        Ok(text) => {
1123            let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1124            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1125            Sha256::digest(normalized.trim_end_matches('\n').as_bytes())
1126        }
1127        Err(_) => Sha256::digest(bytes),
1128    };
1129    crate::hex_lower(&digest)[..16].to_string()
1130}
1131
1132/// One verify-observed prepared-content hash, addressed to the anchor(s) it
1133/// backfills: the `(entity, artifact)` pair a hash-less hash-bearing anchor
1134/// is keyed by in the sidecar, plus the hash the observation computed. The
1135/// verify pass collects these; the engine's sidecar writer records them.
1136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1137pub struct ObservedArtifactHash {
1138    /// The entity id (`mem--slug`) whose anchor the hash belongs to.
1139    pub entity: String,
1140    /// The anchor's artifact reference, exactly as stored.
1141    pub artifact: String,
1142    /// The prepared-content hash observed for the artifact.
1143    pub hash: String,
1144}
1145
1146// ---------------------------------------------------------------------------
1147// Supplied observations
1148// ---------------------------------------------------------------------------
1149
1150/// Typed code for a malformed supplied observation row.
1151pub const INVALID_OBSERVATION_CODE: &str = "INVALID_OBSERVATION";
1152
1153/// One observer-supplied observation row, as it arrives on the wire
1154/// (`memstead verify-anchors --observations`): permissive and string-typed
1155/// so a malformed row refuses with a typed [`ObservationValidationError`]
1156/// before any state changes. The engine never fetches; for a grain it
1157/// cannot observe itself (`url`) this is how an observation enters the one
1158/// resolution funnel.
1159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1160pub struct SuppliedObservationInput {
1161    /// The anchor's artifact reference, exactly as stored (a URL for a
1162    /// `url` row).
1163    #[serde(default)]
1164    pub artifact: Option<String>,
1165    /// The prepared-content hash the observer computed. Exactly one of
1166    /// `hash`, `content`, `absent: true`.
1167    #[serde(default)]
1168    pub hash: Option<String>,
1169    /// The observed artifact CONTENT (UTF-8 text); the engine hashes it
1170    /// under the same canonicalization the write path applies to a `url`
1171    /// anchor's `content`.
1172    #[serde(default)]
1173    pub content: Option<String>,
1174    /// The observer could not retrieve the artifact.
1175    #[serde(default)]
1176    pub absent: Option<bool>,
1177    /// When the observation was made (ISO-8601 UTC, `YYYY-MM-DDTHH:MM:SSZ`
1178    /// or a bare `YYYY-MM-DD`). Defaults to the engine's clock at the run.
1179    #[serde(default)]
1180    pub observed_at: Option<String>,
1181}
1182
1183/// What an observer saw for one artifact.
1184#[derive(Debug, Clone, PartialEq, Eq)]
1185pub enum SuppliedOutcome {
1186    /// The artifact was retrieved; `hash` is its prepared-content hash.
1187    Present { hash: String },
1188    /// The observer could not retrieve the artifact.
1189    Absent,
1190}
1191
1192/// A validated supplied observation.
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct SuppliedObservation {
1195    pub artifact: String,
1196    /// ISO-8601 timestamp of the observation.
1197    pub at: String,
1198    pub outcome: SuppliedOutcome,
1199}
1200
1201/// Why a supplied observation row was refused.
1202#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1203pub enum ObservationValidationError {
1204    #[error("observation row {row}: `artifact` is required and must be non-empty")]
1205    MissingArtifact { row: usize },
1206    #[error(
1207        "observation row {row} (`{artifact}`): give exactly one of `hash`, `content`, or \
1208         `absent: true`"
1209    )]
1210    OutcomeAmbiguous { row: usize, artifact: String },
1211    #[error(
1212        "observation row {row} (`{artifact}`): `observed_at` '{got}' is not an ISO-8601 \
1213         timestamp (`YYYY-MM-DDTHH:MM:SSZ`) or date (`YYYY-MM-DD`)"
1214    )]
1215    BadTimestamp {
1216        row: usize,
1217        artifact: String,
1218        got: String,
1219    },
1220    #[error("observation rows name `{artifact}` more than once (rows {first} and {second})")]
1221    DuplicateArtifact {
1222        artifact: String,
1223        first: usize,
1224        second: usize,
1225    },
1226}
1227
1228impl ObservationValidationError {
1229    /// The stable typed code — always [`INVALID_OBSERVATION_CODE`].
1230    pub fn code(&self) -> &'static str {
1231        INVALID_OBSERVATION_CODE
1232    }
1233
1234    /// Structured recovery detail for the typed envelope.
1235    pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
1236        let mut d = BTreeMap::new();
1237        match self {
1238            ObservationValidationError::MissingArtifact { row } => {
1239                d.insert("row".into(), serde_json::json!(row));
1240                d.insert("field".into(), "artifact".into());
1241            }
1242            ObservationValidationError::OutcomeAmbiguous { row, artifact } => {
1243                d.insert("row".into(), serde_json::json!(row));
1244                d.insert("artifact".into(), serde_json::json!(artifact));
1245                d.insert(
1246                    "expected".into(),
1247                    serde_json::json!("exactly one of `hash`, `content`, `absent: true`"),
1248                );
1249            }
1250            ObservationValidationError::BadTimestamp { row, artifact, got } => {
1251                d.insert("row".into(), serde_json::json!(row));
1252                d.insert("artifact".into(), serde_json::json!(artifact));
1253                d.insert("field".into(), "observed_at".into());
1254                d.insert("got".into(), serde_json::json!(got));
1255            }
1256            ObservationValidationError::DuplicateArtifact {
1257                artifact,
1258                first,
1259                second,
1260            } => {
1261                d.insert("artifact".into(), serde_json::json!(artifact));
1262                d.insert("rows".into(), serde_json::json!([first, second]));
1263            }
1264        }
1265        d
1266    }
1267}
1268
1269/// Accept `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ` (any `T…Z` time part of the
1270/// second-granularity form).
1271fn timestamp_is_wellformed(ts: &str) -> bool {
1272    let b = ts.as_bytes();
1273    let date_ok = b.len() >= 10
1274        && b[..10].iter().enumerate().all(|(i, c)| {
1275            if i == 4 || i == 7 {
1276                *c == b'-'
1277            } else {
1278                c.is_ascii_digit()
1279            }
1280        });
1281    if !date_ok {
1282        return false;
1283    }
1284    if b.len() == 10 {
1285        return true;
1286    }
1287    b.len() == 20
1288        && b[10] == b'T'
1289        && b[19] == b'Z'
1290        && b[11..19].iter().enumerate().all(|(i, c)| {
1291            if i == 2 || i == 5 {
1292                *c == b':'
1293            } else {
1294                c.is_ascii_digit()
1295            }
1296        })
1297}
1298
1299/// Validate a batch of supplied observation rows; all-or-nothing, so a
1300/// malformed row refuses before any state changes. `now` is the timestamp
1301/// stamped on rows that carry no `observed_at`. Returns the observations
1302/// keyed by artifact.
1303pub fn validate_supplied_observations(
1304    rows: &[SuppliedObservationInput],
1305    now: &str,
1306) -> Result<BTreeMap<String, SuppliedObservation>, ObservationValidationError> {
1307    let mut out: BTreeMap<String, SuppliedObservation> = BTreeMap::new();
1308    let mut first_row: BTreeMap<String, usize> = BTreeMap::new();
1309    for (i, row) in rows.iter().enumerate() {
1310        let n = i + 1;
1311        let artifact = row
1312            .artifact
1313            .as_deref()
1314            .map(str::trim)
1315            .filter(|s| !s.is_empty())
1316            .ok_or(ObservationValidationError::MissingArtifact { row: n })?
1317            .to_string();
1318        let absent = row.absent.unwrap_or(false);
1319        let given = usize::from(row.hash.is_some())
1320            + usize::from(row.content.is_some())
1321            + usize::from(absent);
1322        if given != 1 {
1323            return Err(ObservationValidationError::OutcomeAmbiguous { row: n, artifact });
1324        }
1325        let at = match row.observed_at.as_deref().map(str::trim) {
1326            None | Some("") => now.to_string(),
1327            Some(ts) if timestamp_is_wellformed(ts) => ts.to_string(),
1328            Some(ts) => {
1329                return Err(ObservationValidationError::BadTimestamp {
1330                    row: n,
1331                    artifact,
1332                    got: ts.to_string(),
1333                });
1334            }
1335        };
1336        if let Some(first) = first_row.get(&artifact) {
1337            return Err(ObservationValidationError::DuplicateArtifact {
1338                artifact,
1339                first: *first,
1340                second: n,
1341            });
1342        }
1343        let outcome = if absent {
1344            SuppliedOutcome::Absent
1345        } else if let Some(hash) = &row.hash {
1346            SuppliedOutcome::Present {
1347                hash: hash.trim().to_string(),
1348            }
1349        } else {
1350            SuppliedOutcome::Present {
1351                hash: prepared_content_hash(row.content.as_deref().unwrap_or_default().as_bytes()),
1352            }
1353        };
1354        first_row.insert(artifact.clone(), n);
1355        out.insert(
1356            artifact.clone(),
1357            SuppliedObservation {
1358                artifact,
1359                at,
1360                outcome,
1361            },
1362        );
1363    }
1364    Ok(out)
1365}
1366
1367/// Days since the Unix epoch of an ISO timestamp's date part, for aging a
1368/// recorded observation (`unobserved for N days`). `None` when the string
1369/// does not start with a well-formed `YYYY-MM-DD`.
1370pub fn iso_days_since_epoch(ts: &str) -> Option<i64> {
1371    if !timestamp_is_wellformed(ts) {
1372        return None;
1373    }
1374    let y: i64 = ts[..4].parse().ok()?;
1375    let m: u32 = ts[5..7].parse().ok()?;
1376    let d: u32 = ts[8..10].parse().ok()?;
1377    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1378        return None;
1379    }
1380    let y = if m <= 2 { y - 1 } else { y };
1381    let era = if y >= 0 { y } else { y - 399 } / 400;
1382    let yoe = y - era * 400;
1383    let mp = ((m + 9) % 12) as i64;
1384    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
1385    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1386    Some(era * 146097 + doe - 719468)
1387}
1388
1389/// Whole days between a recorded observation and `now` (both ISO), floored
1390/// at zero; `None` when either fails to parse.
1391pub fn days_between(observed_at: &str, now: &str) -> Option<u64> {
1392    let a = iso_days_since_epoch(observed_at)?;
1393    let b = iso_days_since_epoch(now)?;
1394    Some((b - a).max(0) as u64)
1395}
1396
1397// ---------------------------------------------------------------------------
1398// Resolution
1399// ---------------------------------------------------------------------------
1400
1401/// The resolved state of one anchor against the current medium.
1402#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1403#[serde(rename_all = "lowercase")]
1404pub enum AnchorState {
1405    /// The artifact is present and matches (hash equal, or a non-hash
1406    /// class whose artifact still exists).
1407    Resolves,
1408    /// The artifact is present but its prepared-content hash differs and
1409    /// the medium is `stable` — a real content drift.
1410    Drifted,
1411    /// The artifact is present but drift cannot be asserted — the medium
1412    /// is `unstable`, or the hash is unavailable on one side. Flagged for
1413    /// re-examination, never reported as drift.
1414    Recheck,
1415    /// The artifact the anchor references is no longer present in the
1416    /// medium.
1417    Orphaned,
1418}
1419
1420impl AnchorState {
1421    /// Stable wire form.
1422    pub fn as_wire(&self) -> &'static str {
1423        match self {
1424            AnchorState::Resolves => "resolves",
1425            AnchorState::Drifted => "drifted",
1426            AnchorState::Recheck => "recheck",
1427            AnchorState::Orphaned => "orphaned",
1428        }
1429    }
1430}
1431
1432/// What the engine observed about an anchor's artifact when resolving.
1433#[derive(Debug, Clone, PartialEq, Eq)]
1434pub enum ArtifactObservation {
1435    /// The artifact could not be found in the medium.
1436    Absent,
1437    /// The artifact is present; `current_hash` is its prepared-content
1438    /// hash when the medium could compute one (`None` when the medium has
1439    /// no hash for it this pass — e.g. enumeration without preparation).
1440    Present { current_hash: Option<String> },
1441}
1442
1443/// Resolve one anchor against a current observation, honouring the class's
1444/// hash semantics and the medium's declared stability.
1445///
1446/// - `authored` / `informed-by` are excluded from hash-drift adjudication:
1447///   they [`Resolves`](AnchorState::Resolves) as long as the artifact
1448///   exists, [`Orphaned`](AnchorState::Orphaned) when it does not — a
1449///   content change never produces a drift state for them.
1450/// - `anchored` / `derived` compare the recorded prepared-content hash to
1451///   the current one: equal ⇒ resolves; different ⇒ `drifted` on a stable
1452///   medium, `recheck` on an unstable one; unavailable on either side ⇒
1453///   `recheck` (cannot adjudicate).
1454pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
1455    let current_hash = match observation {
1456        ArtifactObservation::Absent => return AnchorState::Orphaned,
1457        ArtifactObservation::Present { current_hash } => current_hash,
1458    };
1459    if !anchor.class.is_hash_bearing() {
1460        return AnchorState::Resolves;
1461    }
1462    match (&anchor.hash, current_hash) {
1463        (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
1464        (Some(_), Some(_)) => match anchor.hash_stability {
1465            AnchorHashStability::Stable => AnchorState::Drifted,
1466            AnchorHashStability::Unstable => AnchorState::Recheck,
1467        },
1468        // Missing hash on either side — cannot adjudicate drift.
1469        _ => AnchorState::Recheck,
1470    }
1471}
1472
1473/// Per-entity provenance-class + grain composition, computed from an
1474/// entity's anchor list. Tree-grain fan-out is surfaced distinctly so a
1475/// single entity anchored to a large tree is never laundered into
1476/// full per-file credit — the count of tree anchors is visible on its own
1477/// axis, and downstream (E3b) reads the fan-out counts from resolution.
1478#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1479pub struct EntityAnchorComposition {
1480    /// Anchor count keyed by provenance-class wire string.
1481    pub by_class: BTreeMap<String, usize>,
1482    /// Anchor count keyed by grain wire string.
1483    pub by_grain: BTreeMap<String, usize>,
1484    /// The `derived_from` input lists of every `derived` anchor, in
1485    /// anchor order — E3b's derived-input provenance.
1486    pub derived_inputs: Vec<Vec<String>>,
1487    /// Artifact refs of every `tree`-grain anchor — the fan-out axis. A
1488    /// tree anchor is one row here regardless of how many files the tree
1489    /// contains; the file count is an observation resolution supplies, not
1490    /// a credit this composition grants.
1491    pub tree_grain_artifacts: Vec<String>,
1492}
1493
1494/// Compose an entity's anchors into class/grain counts, derived inputs,
1495/// and the tree-grain fan-out axis.
1496pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
1497    let mut comp = EntityAnchorComposition::default();
1498    for a in anchors {
1499        *comp
1500            .by_class
1501            .entry(a.class.as_wire().to_string())
1502            .or_insert(0) += 1;
1503        *comp
1504            .by_grain
1505            .entry(a.grain.as_wire().to_string())
1506            .or_insert(0) += 1;
1507        if a.class == AnchorProvenanceClass::Derived {
1508            comp.derived_inputs.push(a.derived_from.clone());
1509        }
1510        if a.grain == AnchorGrain::Tree {
1511            comp.tree_grain_artifacts.push(a.artifact.clone());
1512        }
1513    }
1514    comp
1515}
1516
1517// ---------------------------------------------------------------------------
1518// Sidecar document
1519// ---------------------------------------------------------------------------
1520
1521/// The engine-owned anchors sidecar document persisted at
1522/// [`ANCHOR_SIDECAR_PATH`] on the mem branch: entity id → its anchors.
1523///
1524/// Written only through engine commits (the [`crate::backend::MemBackend`]
1525/// sidecar seam). Rename rewrites the key atomically in the same commit as
1526/// the entity move; delete drops the key in the same commit as the entity
1527/// delete.
1528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1529pub struct AnchorSidecar {
1530    /// Document schema version.
1531    pub version: u32,
1532    /// Entity id (`mem--slug`) → its anchors. An entity with no anchors
1533    /// carries no key (an empty vec is pruned on write).
1534    #[serde(default)]
1535    pub entities: BTreeMap<String, Vec<Anchor>>,
1536}
1537
1538impl Default for AnchorSidecar {
1539    fn default() -> Self {
1540        Self {
1541            version: ANCHOR_SIDECAR_VERSION,
1542            entities: BTreeMap::new(),
1543        }
1544    }
1545}
1546
1547impl AnchorSidecar {
1548    /// Parse sidecar bytes; an absent/empty payload yields an empty
1549    /// document so callers need not special-case a fresh mem.
1550    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
1551        if bytes.iter().all(u8::is_ascii_whitespace) {
1552            return Ok(Self::default());
1553        }
1554        let sidecar: Self = serde_json::from_slice(bytes)?;
1555        // The version field is a contract, not decoration. Every sibling
1556        // store refuses an unknown one — the binding record with
1557        // `UNKNOWN_BINDING_VERSION`, the workspace stores with
1558        // `WORKSPACE_STORE_FORMAT_MISMATCH` — and this one silently accepted
1559        // it, so a sidecar written by a future engine parsed as whatever
1560        // today's field names happened to match and verified CLEAN. Reading
1561        // an unknown format optimistically is how a measurement ends up
1562        // confidently describing something it does not understand.
1563        if !ANCHOR_SIDECAR_VERSIONS_READ.contains(&sidecar.version) {
1564            return Err(serde::de::Error::custom(format!(
1565                "unsupported anchors sidecar version {} (this engine reads versions {}) — \
1566                 the file was written by a different engine; upgrade, or remove the sidecar \
1567                 to re-record anchors",
1568                sidecar.version,
1569                ANCHOR_SIDECAR_VERSIONS_READ
1570                    .iter()
1571                    .map(u32::to_string)
1572                    .collect::<Vec<_>>()
1573                    .join(", ")
1574            )));
1575        }
1576        // An older readable version is upgraded in memory: the rows are
1577        // unchanged (a version-2 field is simply absent on them) and the
1578        // next write persists the current version.
1579        let mut sidecar = sidecar;
1580        sidecar.version = ANCHOR_SIDECAR_VERSION;
1581        Ok(sidecar)
1582    }
1583
1584    /// Serialise to canonical pretty JSON with a trailing newline —
1585    /// diff-friendly on the mem branch.
1586    pub fn to_bytes(&self) -> Vec<u8> {
1587        let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
1588        s.push('\n');
1589        s.into_bytes()
1590    }
1591
1592    /// The anchors recorded for `entity_id`, or an empty slice.
1593    pub fn get(&self, entity_id: &str) -> &[Anchor] {
1594        self.entities
1595            .get(entity_id)
1596            .map(Vec::as_slice)
1597            .unwrap_or(&[])
1598    }
1599
1600    /// Replace `entity_id`'s anchors. An empty list prunes the key so the
1601    /// sidecar never accumulates empty rows.
1602    pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
1603        if anchors.is_empty() {
1604            self.entities.remove(entity_id);
1605        } else {
1606            self.entities.insert(entity_id.to_string(), anchors);
1607        }
1608    }
1609
1610    /// Merge `incoming` into `entity_id`'s anchor row after applying
1611    /// `unsets` — the write-path set arithmetic.
1612    ///
1613    /// Unset applies **first**: each selector removes its matching anchors
1614    /// (a selector matching nothing is a no-op). Then each incoming anchor
1615    /// **replaces** the surviving anchor with the same
1616    /// `(artifact, grain, class)` triple in place, and **appends**
1617    /// otherwise — untouched anchors keep their bytes and their position.
1618    /// Writing anchors never removes an anchor the call did not name in
1619    /// `unsets`; an empty `incoming` merges nothing. A row emptied by
1620    /// unsets prunes its key so the sidecar never accumulates empty rows.
1621    pub fn merge(&mut self, entity_id: &str, unsets: &[AnchorUnset], incoming: Vec<Anchor>) {
1622        let mut row = self.entities.remove(entity_id).unwrap_or_default();
1623        row.retain(|a| !unsets.iter().any(|u| u.matches(a)));
1624        for mut anchor in incoming {
1625            match row.iter_mut().find(|e| {
1626                e.artifact == anchor.artifact && e.grain == anchor.grain && e.class == anchor.class
1627            }) {
1628                Some(existing) => {
1629                    // Carry the stored baseline forward when the incoming row
1630                    // does not mention one (consistency-sweep 03/03,
1631                    // criterion 5). A re-pin usually exists to update the
1632                    // artifact reference, and dropping the hash made the next
1633                    // verify re-baseline silently, so drift became
1634                    // unfalsifiable with nothing recording that it had. A
1635                    // caller who supplies a hash still replaces it, and one
1636                    // who means to CLEAR a baseline unsets the row and writes
1637                    // it fresh — unsets are applied above, before this merge.
1638                    if anchor.hash.is_none()
1639                        && let Some(kept) = existing.hash.clone()
1640                    {
1641                        anchor.hash = Some(kept);
1642                        anchor.hash_source = existing.hash_source;
1643                    }
1644                    *existing = anchor;
1645                }
1646                None => row.push(anchor),
1647            }
1648        }
1649        if !row.is_empty() {
1650            self.entities.insert(entity_id.to_string(), row);
1651        }
1652    }
1653
1654    /// Blank every artifact reference — `artifact` and each `derived_from`
1655    /// entry — to [`REDACTED_ARTIFACT_SENTINEL`], keeping everything else:
1656    /// class, grain, `at_version`, hash, hash-stability, binding, source,
1657    /// and the per-entity anchor counts. Redact, not strip: a consumer
1658    /// still reads *how strongly* each entity claims fidelity to a source
1659    /// without learning *which* source. Publish-time only by design — no
1660    /// engine path calls this against workspace state.
1661    pub fn redact_artifact_references(&mut self) {
1662        for anchors in self.entities.values_mut() {
1663            for anchor in anchors {
1664                anchor.artifact = REDACTED_ARTIFACT_SENTINEL.to_string();
1665                for input in &mut anchor.derived_from {
1666                    *input = REDACTED_ARTIFACT_SENTINEL.to_string();
1667                }
1668            }
1669        }
1670    }
1671
1672    /// Structural check on artifact references: every `artifact` and every
1673    /// `derived_from` entry must be non-empty. The mutation surface never
1674    /// admits an empty reference (`INVALID_ANCHOR`), so a sidecar carrying
1675    /// one is corruption — including a botched redaction that blanked to
1676    /// nothing instead of the pinned sentinel. Returns the first offence.
1677    pub fn validate_artifact_references(&self) -> Result<(), String> {
1678        for (entity_id, anchors) in &self.entities {
1679            for anchor in anchors {
1680                if anchor.artifact.trim().is_empty() {
1681                    return Err(format!(
1682                        "entity `{entity_id}` carries an anchor with an empty artifact \
1683                         reference"
1684                    ));
1685                }
1686                if anchor.derived_from.iter().any(|d| d.trim().is_empty()) {
1687                    return Err(format!(
1688                        "entity `{entity_id}` carries an anchor with an empty \
1689                         `derived_from` entry"
1690                    ));
1691                }
1692            }
1693        }
1694        Ok(())
1695    }
1696
1697    /// Drop `entity_id`'s anchors entirely (delete leg). Idempotent.
1698    pub fn remove(&mut self, entity_id: &str) {
1699        self.entities.remove(entity_id);
1700    }
1701
1702    /// Move `from`'s anchors to `to` (rename leg), leaving zero rows under
1703    /// the old id. No-op when `from` has no anchors. When `to` already has
1704    /// anchors they are overwritten — a rename onto a live id is refused
1705    /// upstream, so this is the residual-stub case only.
1706    pub fn rename(&mut self, from: &str, to: &str) {
1707        if let Some(anchors) = self.entities.remove(from) {
1708            self.entities.insert(to.to_string(), anchors);
1709        }
1710    }
1711
1712    /// Whether the document holds no anchors for any entity.
1713    pub fn is_empty(&self) -> bool {
1714        self.entities.is_empty()
1715    }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use super::*;
1721
1722    /// Redaction blanks exactly the two artifact-reference fields — to the
1723    /// pinned sentinel, never removal — and keeps everything else: class,
1724    /// grain, `at_version`, hash, hash-stability, binding, source, and the
1725    /// per-entity anchor counts.
1726    #[test]
1727    fn redaction_blanks_references_and_keeps_trust_metadata() {
1728        let mut sidecar = AnchorSidecar::default();
1729        sidecar.set(
1730            "m--alpha",
1731            vec![
1732                Anchor {
1733                    artifact: "src/lib.rs".into(),
1734                    grain: AnchorGrain::File,
1735                    class: AnchorProvenanceClass::Anchored,
1736                    at_version: Some(AnchorVersion::Commit("abc123".into())),
1737                    hash: Some("h1".into()),
1738                    hash_stability: AnchorHashStability::Stable,
1739                    derived_from: vec![],
1740                    binding: Some("bhash".into()),
1741                    source: Some("source-tree".into()),
1742                    span_unvalidated: false,
1743                    hash_source: None,
1744                    last_observed: None,
1745                },
1746                Anchor {
1747                    artifact: "docs/summary.md".into(),
1748                    grain: AnchorGrain::File,
1749                    class: AnchorProvenanceClass::Derived,
1750                    at_version: None,
1751                    hash: Some("h2".into()),
1752                    hash_stability: AnchorHashStability::Unstable,
1753                    derived_from: vec!["notes/a.md".into(), "notes/b.md".into()],
1754                    binding: None,
1755                    source: None,
1756                    span_unvalidated: false,
1757                    hash_source: None,
1758                    last_observed: None,
1759                },
1760            ],
1761        );
1762
1763        sidecar.redact_artifact_references();
1764
1765        let anchors = sidecar.get("m--alpha");
1766        assert_eq!(anchors.len(), 2, "no anchor entry is dropped");
1767        for a in anchors {
1768            assert_eq!(a.artifact, REDACTED_ARTIFACT_SENTINEL);
1769            for d in &a.derived_from {
1770                assert_eq!(d, REDACTED_ARTIFACT_SENTINEL);
1771            }
1772        }
1773        assert_eq!(
1774            anchors[0].at_version,
1775            Some(AnchorVersion::Commit("abc123".into()))
1776        );
1777        assert_eq!(anchors[0].hash.as_deref(), Some("h1"));
1778        assert_eq!(anchors[0].binding.as_deref(), Some("bhash"));
1779        assert_eq!(anchors[0].source.as_deref(), Some("source-tree"));
1780        assert_eq!(anchors[1].class, AnchorProvenanceClass::Derived);
1781        assert_eq!(anchors[1].derived_from.len(), 2, "derivation arity kept");
1782        // A redacted sidecar is structurally valid — the sentinel is not
1783        // an empty reference.
1784        sidecar.validate_artifact_references().unwrap();
1785    }
1786
1787    /// The structural reference check refuses empty `artifact` and empty
1788    /// `derived_from` entries — including a botched redaction that blanked
1789    /// to nothing instead of the sentinel.
1790    #[test]
1791    fn empty_artifact_references_are_refused() {
1792        let mut sidecar = AnchorSidecar::default();
1793        sidecar.set(
1794            "m--alpha",
1795            vec![Anchor {
1796                artifact: "".into(),
1797                grain: AnchorGrain::File,
1798                class: AnchorProvenanceClass::Anchored,
1799                at_version: None,
1800                hash: None,
1801                hash_stability: AnchorHashStability::Stable,
1802                derived_from: vec![],
1803                binding: None,
1804                source: None,
1805                span_unvalidated: false,
1806                hash_source: None,
1807                last_observed: None,
1808            }],
1809        );
1810        assert!(sidecar.validate_artifact_references().is_err());
1811
1812        let mut sidecar = AnchorSidecar::default();
1813        sidecar.set(
1814            "m--beta",
1815            vec![Anchor {
1816                artifact: "docs/x.md".into(),
1817                grain: AnchorGrain::File,
1818                class: AnchorProvenanceClass::Derived,
1819                at_version: None,
1820                hash: None,
1821                hash_stability: AnchorHashStability::Stable,
1822                derived_from: vec!["  ".into()],
1823                binding: None,
1824                source: None,
1825                span_unvalidated: false,
1826                hash_source: None,
1827                last_observed: None,
1828            }],
1829        );
1830        assert!(sidecar.validate_artifact_references().is_err());
1831    }
1832
1833    // -- wire vocabulary is the contract -----------------------------------
1834
1835    #[test]
1836    fn class_wire_strings_are_stable() {
1837        assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
1838        assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
1839        assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
1840        assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
1841        for w in AnchorProvenanceClass::WIRE_VALUES {
1842            assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
1843        }
1844        assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
1845    }
1846
1847    #[test]
1848    fn grain_wire_strings_are_stable() {
1849        for w in AnchorGrain::WIRE_VALUES {
1850            assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
1851        }
1852        assert_eq!(
1853            AnchorGrain::WIRE_VALUES,
1854            &["span", "file", "tree", "url", "entity"]
1855        );
1856        assert!(AnchorGrain::from_wire("chunk").is_none());
1857    }
1858
1859    #[test]
1860    fn stability_and_state_wire_strings_are_stable() {
1861        assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
1862        assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
1863        assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
1864        assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
1865        assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
1866        assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
1867    }
1868
1869    #[test]
1870    fn only_anchored_and_derived_are_hash_bearing() {
1871        assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
1872        assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
1873        assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
1874        assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
1875    }
1876
1877    // -- grain / namespace matrix ------------------------------------------
1878
1879    #[test]
1880    fn grain_namespace_support_matches_capability_matrix() {
1881        // path-shaped grains need path / path+commit.
1882        for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
1883            assert!(g.supported_by_namespace("path"));
1884            assert!(g.supported_by_namespace("path+commit"));
1885            assert!(!g.supported_by_namespace("url"));
1886            assert!(!g.supported_by_namespace("entity"));
1887        }
1888        assert!(AnchorGrain::Url.supported_by_namespace("url"));
1889        // A URL is an absolute reference: admitted beside every medium.
1890        assert!(AnchorGrain::Url.supported_by_namespace("path"));
1891        assert!(AnchorGrain::Url.supported_by_namespace("entity"));
1892        assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
1893        assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
1894    }
1895
1896    // -- validation refusals -----------------------------------------------
1897
1898    fn valid_input() -> AnchorInput {
1899        AnchorInput {
1900            artifact: Some("src/lib.rs".into()),
1901            grain: Some("file".into()),
1902            class: Some("anchored".into()),
1903            hash_stability: Some("stable".into()),
1904            hash: Some("abc123".into()),
1905            ..Default::default()
1906        }
1907    }
1908
1909    fn span_input(artifact: &str) -> AnchorInput {
1910        AnchorInput {
1911            artifact: Some(artifact.into()),
1912            grain: Some("span".into()),
1913            class: Some("anchored".into()),
1914            ..Default::default()
1915        }
1916    }
1917
1918    /// Criterion 1 (consistency-sweep 03/03): a locator that can never
1919    /// address anything is refused at the moment of writing. Each of these
1920    /// used to write successfully and could then never be adjudicated.
1921    #[test]
1922    fn a_span_locator_that_addresses_nothing_is_refused() {
1923        for artifact in [
1924            "src/lib.rs#",      // announces a span, names none
1925            "src/lib.rs#   ",   // the same, in whitespace
1926            "src/lib.rs#L0",    // lines are 1-based
1927            "src/lib.rs#L0-L4", // and so is a range's start
1928            "src/lib.rs#L9-L2", // ends before it starts
1929            "src/lib.rs#L4-L",  // half a range
1930            "src/lib.rs#L4-x",  // a range that stops being one
1931        ] {
1932            let err = span_input(artifact)
1933                .validate(Some(("codebase", "path")))
1934                .expect_err(artifact);
1935            assert!(
1936                matches!(err, AnchorValidationError::SpanLocatorUnusable { .. }),
1937                "{artifact} refused as {err:?}"
1938            );
1939            assert_eq!(err.code(), INVALID_ANCHOR_CODE);
1940            assert!(err.detail().contains_key("expected"), "carries the repair");
1941        }
1942    }
1943
1944    /// Criterion 4's first half: a span that names something real still
1945    /// writes. `Lx` forms within the artifact, a preparation's unit key, and
1946    /// a bare path (which addresses the whole file, and is what a span's hash
1947    /// covers anyway) are all legal.
1948    #[test]
1949    fn a_usable_span_locator_still_writes() {
1950        for artifact in [
1951            "src/lib.rs",
1952            "src/lib.rs#L1",
1953            "src/lib.rs#L4-L7",
1954            "logs/ops.md#2026-08-25T00:00:00",
1955        ] {
1956            span_input(artifact)
1957                .validate(Some(("codebase", "path")))
1958                .unwrap_or_else(|e| panic!("{artifact} refused: {e}"));
1959        }
1960    }
1961
1962    /// Criterion 2: where the content is already in hand, a range beyond the
1963    /// artifact's end is refused rather than stored as an anchor pointing at
1964    /// lines the file does not have.
1965    #[test]
1966    fn a_span_beyond_supplied_content_is_refused() {
1967        let mut i = span_input("src/lib.rs#L2-L9");
1968        i.content = Some(
1969            "one
1970two
1971three
1972"
1973            .into(),
1974        );
1975        let err = i.validate(Some(("codebase", "path"))).unwrap_err();
1976        match err {
1977            AnchorValidationError::SpanOutsideContent { lines, .. } => assert_eq!(lines, 3),
1978            other => panic!("wrong refusal: {other:?}"),
1979        }
1980
1981        let mut ok = span_input("src/lib.rs#L2-L3");
1982        ok.content = Some(
1983            "one
1984two
1985three
1986"
1987            .into(),
1988        );
1989        let a = ok.validate(Some(("codebase", "path"))).unwrap();
1990        assert!(
1991            !a.span_unvalidated,
1992            "a span checked against content is not unvalidated"
1993        );
1994    }
1995
1996    /// Criterion 3: where the write path holds no content, the span cannot be
1997    /// checked without a read it deliberately does not perform. The anchor is
1998    /// accepted and the row says the span is unverified, so no later surface
1999    /// reports it as adjudicated.
2000    #[test]
2001    fn an_uncheckable_span_is_accepted_and_recorded_as_unchecked() {
2002        let a = span_input("src/lib.rs#L4-L7")
2003            .validate(Some(("codebase", "path")))
2004            .unwrap();
2005        assert!(a.span_unvalidated);
2006
2007        let whole_file = span_input("src/lib.rs")
2008            .validate(Some(("codebase", "path")))
2009            .unwrap();
2010        assert!(
2011            !whole_file.span_unvalidated,
2012            "no locator addresses the whole artifact, which the existence gate checks"
2013        );
2014
2015        let file_grain = valid_input().validate(Some(("codebase", "path"))).unwrap();
2016        assert!(!file_grain.span_unvalidated, "never set off the span grain");
2017    }
2018
2019    /// Criterion 8: a hash the writer supplied is recorded as theirs, so a
2020    /// reader can later tell it from one the backfill inferred.
2021    #[test]
2022    fn an_authored_hash_records_that_the_author_pinned_it() {
2023        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2024        assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2025
2026        let mut hashless = valid_input();
2027        hashless.hash = None;
2028        let b = hashless.validate(Some(("codebase", "path"))).unwrap();
2029        assert_eq!(b.hash_source, None, "no baseline, no origin to record");
2030    }
2031
2032    /// Criteria 5 and 6: a re-pin that says nothing about the hash keeps the
2033    /// baseline it did not mention, one that supplies a hash replaces it, and
2034    /// unsetting the row first is the explicit way to clear it.
2035    #[test]
2036    fn a_re_pin_keeps_the_baseline_it_did_not_mention() {
2037        let mut sc = AnchorSidecar::default();
2038        let mut pinned = file_anchor("src/a.rs", "h-original");
2039        pinned.hash_source = Some(AnchorHashSource::Author);
2040        sc.set("m--e", vec![pinned]);
2041
2042        let mut repin = file_anchor("src/a.rs", "");
2043        repin.hash = None;
2044        repin.hash_source = None;
2045        sc.merge("m--e", &[], vec![repin]);
2046        let row = &sc.entities["m--e"][0];
2047        assert_eq!(
2048            row.hash.as_deref(),
2049            Some("h-original"),
2050            "the baseline the caller did not mention survives"
2051        );
2052        assert_eq!(row.hash_source, Some(AnchorHashSource::Author));
2053
2054        sc.merge("m--e", &[], vec![file_anchor("src/a.rs", "h-new")]);
2055        assert_eq!(
2056            sc.entities["m--e"][0].hash.as_deref(),
2057            Some("h-new"),
2058            "a supplied hash still replaces"
2059        );
2060
2061        // The explicit clear: unset the row, then write it fresh. Unsets are
2062        // applied before the merge, so the old row is gone first.
2063        let unset = AnchorUnset {
2064            artifact: "src/a.rs".into(),
2065            grain: None,
2066            class: None,
2067        };
2068        let mut fresh = file_anchor("src/a.rs", "");
2069        fresh.hash = None;
2070        fresh.hash_source = None;
2071        sc.merge("m--e", &[unset], vec![fresh]);
2072        assert_eq!(
2073            sc.entities["m--e"][0].hash, None,
2074            "unset-then-write is how a caller clears a baseline"
2075        );
2076    }
2077
2078    #[test]
2079    fn validate_accepts_a_well_formed_anchor() {
2080        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
2081        assert_eq!(a.artifact, "src/lib.rs");
2082        assert_eq!(a.grain, AnchorGrain::File);
2083        assert_eq!(a.class, AnchorProvenanceClass::Anchored);
2084        assert_eq!(a.hash.as_deref(), Some("abc123"));
2085        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
2086    }
2087
2088    /// Path grains keep their `stable` default — pinned, because the
2089    /// per-grain default that gives `url` its `unstable` must not leak.
2090    #[test]
2091    fn validate_defaults_hash_stability_to_stable() {
2092        for grain in ["span", "file", "tree"] {
2093            let mut i = valid_input();
2094            i.grain = Some(grain.into());
2095            i.hash_stability = None;
2096            let a = i.validate(None).unwrap();
2097            assert_eq!(a.hash_stability, AnchorHashStability::Stable, "{grain}");
2098        }
2099        let mut e = valid_input();
2100        e.grain = Some("entity".into());
2101        e.artifact = Some("m--e".into());
2102        e.hash_stability = None;
2103        assert_eq!(
2104            e.validate(None).unwrap().hash_stability,
2105            AnchorHashStability::Stable
2106        );
2107    }
2108
2109    /// A `url` anchor defaults to `unstable` (a served page is a moving
2110    /// target — a hash break resolves `recheck`, never `drifted`) unless the
2111    /// author asserts `stable`.
2112    #[test]
2113    fn validate_defaults_url_grain_to_unstable_unless_declared() {
2114        let mut i = valid_input();
2115        i.grain = Some("url".into());
2116        i.artifact = Some("https://example.invalid/doc".into());
2117        i.hash_stability = None;
2118        assert_eq!(
2119            i.validate(None).unwrap().hash_stability,
2120            AnchorHashStability::Unstable
2121        );
2122        i.hash_stability = Some("stable".into());
2123        assert_eq!(
2124            i.validate(None).unwrap().hash_stability,
2125            AnchorHashStability::Stable
2126        );
2127    }
2128
2129    /// Supplied `content` becomes the registry's prepared hash: for a `url`
2130    /// anchor the same canonicalization the path grains use over what the
2131    /// observer read; for `file`/`span` the hash the engine would compute
2132    /// from the file itself. `hash` beside it is refused, as is content on
2133    /// a grain the registry never prepares from bytes, or on a non-hash
2134    /// class.
2135    #[test]
2136    fn content_yields_the_prepared_hash_through_the_registry() {
2137        let mut u = valid_input();
2138        u.grain = Some("url".into());
2139        u.artifact = Some("https://example.invalid/doc".into());
2140        u.hash = None;
2141        u.hash_stability = None;
2142        u.content = Some("<p>hello</p>\r\n".into());
2143        let a = u.validate(None).unwrap();
2144        assert_eq!(
2145            a.hash.as_deref(),
2146            Some(crate::preparation::url_prepared_hash(b"<p>hello</p>\n").as_str())
2147        );
2148        assert_eq!(a.hash_stability, AnchorHashStability::Unstable);
2149
2150        let mut f = valid_input();
2151        f.hash = None;
2152        f.content = Some("fn a() {}\n".into());
2153        assert_eq!(
2154            f.validate(None).unwrap().hash.as_deref(),
2155            Some(prepared_content_hash(b"fn a() {}").as_str())
2156        );
2157
2158        let mut both = valid_input();
2159        both.content = Some("x".into());
2160        assert_eq!(
2161            both.validate(None).unwrap_err(),
2162            AnchorValidationError::ContentAndHash
2163        );
2164
2165        let mut ent = valid_input();
2166        ent.grain = Some("entity".into());
2167        ent.artifact = Some("m--e".into());
2168        ent.hash = None;
2169        ent.content = Some("x".into());
2170        let err = ent.validate(None).unwrap_err();
2171        assert_eq!(
2172            err,
2173            AnchorValidationError::ContentNotAcceptedForGrain { grain: "entity" }
2174        );
2175        assert_eq!(err.detail()["field"], "content");
2176
2177        let mut tree = valid_input();
2178        tree.grain = Some("tree".into());
2179        tree.hash = None;
2180        tree.content = Some("x".into());
2181        assert!(matches!(
2182            tree.validate(None).unwrap_err(),
2183            AnchorValidationError::ContentNotAcceptedForGrain { grain: "tree" }
2184        ));
2185
2186        let mut informed = valid_input();
2187        informed.class = Some("informed-by".into());
2188        informed.hash = None;
2189        informed.content = Some("x".into());
2190        assert!(matches!(
2191            informed.validate(None).unwrap_err(),
2192            AnchorValidationError::HashOnNonHashClass { .. }
2193        ));
2194    }
2195
2196    #[test]
2197    fn validate_refuses_unknown_class() {
2198        let mut i = valid_input();
2199        i.class = Some("guessed".into());
2200        let err = i.validate(None).unwrap_err();
2201        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2202        assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
2203        assert_eq!(err.detail()["field"], serde_json::json!("class"));
2204    }
2205
2206    #[test]
2207    fn validate_refuses_unknown_grain() {
2208        let mut i = valid_input();
2209        i.grain = Some("paragraph".into());
2210        let err = i.validate(None).unwrap_err();
2211        assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
2212    }
2213
2214    #[test]
2215    fn validate_refuses_missing_artifact() {
2216        let mut i = valid_input();
2217        i.artifact = Some("   ".into());
2218        let err = i.validate(None).unwrap_err();
2219        assert!(matches!(err, AnchorValidationError::MissingArtifact));
2220        i.artifact = None;
2221        assert!(matches!(
2222            valid_input_with_artifact(None).validate(None).unwrap_err(),
2223            AnchorValidationError::MissingArtifact
2224        ));
2225        let _ = i;
2226    }
2227
2228    fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
2229        AnchorInput {
2230            artifact: a,
2231            ..valid_input()
2232        }
2233    }
2234
2235    #[test]
2236    fn validate_refuses_hash_on_non_hash_class() {
2237        let mut i = valid_input();
2238        i.class = Some("authored".into());
2239        // hash still supplied → refuse
2240        let err = i.validate(None).unwrap_err();
2241        assert!(matches!(
2242            err,
2243            AnchorValidationError::HashOnNonHashClass { class: "authored" }
2244        ));
2245    }
2246
2247    #[test]
2248    fn validate_accepts_non_hash_class_without_hash() {
2249        let mut i = valid_input();
2250        i.class = Some("informed-by".into());
2251        i.hash = None;
2252        let a = i.validate(None).unwrap();
2253        assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
2254        assert!(a.hash.is_none());
2255    }
2256
2257    #[test]
2258    fn validate_refuses_grain_unsupported_by_medium_namespace() {
2259        // span grain on a web (url namespace) medium.
2260        let mut i = valid_input();
2261        i.grain = Some("span".into());
2262        i.class = Some("authored".into());
2263        i.hash = None;
2264        let err = i.validate(Some(("web", "url"))).unwrap_err();
2265        match err {
2266            AnchorValidationError::GrainNamespaceUnsupported {
2267                grain,
2268                anchor_namespace,
2269                ..
2270            } => {
2271                assert_eq!(grain, "span");
2272                assert_eq!(anchor_namespace, "url");
2273            }
2274            other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
2275        }
2276    }
2277
2278    #[test]
2279    fn validate_skips_namespace_check_without_medium_context() {
2280        // span grain, no medium → namespace rule not applied.
2281        let mut i = valid_input();
2282        i.grain = Some("span".into());
2283        assert!(i.validate(None).is_ok());
2284    }
2285
2286    // -- prepared-content hash ----------------------------------------------
2287
2288    /// The prepared form is stable across meaningless byte noise: BOM,
2289    /// line-ending convention, and final-newline presence never move the
2290    /// hash — a real content change always does.
2291    #[test]
2292    fn prepared_hash_is_stable_across_byte_noise() {
2293        let base = prepared_content_hash(b"fn a() {}\nfn b() {}\n");
2294        // CRLF and lone-CR line endings normalize away.
2295        assert_eq!(prepared_content_hash(b"fn a() {}\r\nfn b() {}\r\n"), base);
2296        assert_eq!(prepared_content_hash(b"fn a() {}\rfn b() {}\r"), base);
2297        // Final-newline presence (missing, single, several) is noise.
2298        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}"), base);
2299        assert_eq!(prepared_content_hash(b"fn a() {}\nfn b() {}\n\n\n"), base);
2300        // A leading UTF-8 BOM is stripped.
2301        assert_eq!(
2302            prepared_content_hash("\u{feff}fn a() {}\nfn b() {}\n".as_bytes()),
2303            base
2304        );
2305        // A real content change moves the hash.
2306        assert_ne!(prepared_content_hash(b"fn a() {}\nfn c() {}\n"), base);
2307        // House hash shape: 16 lowercase hex chars.
2308        assert_eq!(base.len(), 16);
2309        assert!(
2310            base.chars()
2311                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
2312        );
2313    }
2314
2315    /// Interior whitespace is content, not noise: a trailing space inside a
2316    /// line (markdown hard break) changes the hash.
2317    #[test]
2318    fn prepared_hash_preserves_interior_whitespace() {
2319        assert_ne!(
2320            prepared_content_hash(b"line one  \nline two\n"),
2321            prepared_content_hash(b"line one\nline two\n")
2322        );
2323    }
2324
2325    /// Non-UTF-8 bytes hash raw — no text canonicalization is applied, and
2326    /// any byte change moves the hash.
2327    #[test]
2328    fn prepared_hash_hashes_binary_bytes_raw() {
2329        let bin_a = [0xff_u8, 0xfe, 0x00, 0x0d, 0x0a];
2330        let bin_b = [0xff_u8, 0xfe, 0x00, 0x0a];
2331        assert_ne!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_b));
2332        // Deterministic.
2333        assert_eq!(prepared_content_hash(&bin_a), prepared_content_hash(&bin_a));
2334    }
2335
2336    // -- resolution --------------------------------------------------------
2337
2338    fn anchor(
2339        class: AnchorProvenanceClass,
2340        hash: Option<&str>,
2341        stab: AnchorHashStability,
2342    ) -> Anchor {
2343        Anchor {
2344            artifact: "src/lib.rs".into(),
2345            grain: AnchorGrain::File,
2346            class,
2347            at_version: None,
2348            hash: hash.map(str::to_string),
2349            hash_stability: stab,
2350            derived_from: Vec::new(),
2351            binding: None,
2352            source: None,
2353            span_unvalidated: false,
2354            hash_source: None,
2355            last_observed: None,
2356        }
2357    }
2358
2359    #[test]
2360    fn resolves_when_hash_matches() {
2361        let a = anchor(
2362            AnchorProvenanceClass::Anchored,
2363            Some("h1"),
2364            AnchorHashStability::Stable,
2365        );
2366        let obs = ArtifactObservation::Present {
2367            current_hash: Some("h1".into()),
2368        };
2369        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2370    }
2371
2372    #[test]
2373    fn stable_hash_break_drifts_unstable_rechecks() {
2374        let stable = anchor(
2375            AnchorProvenanceClass::Anchored,
2376            Some("h1"),
2377            AnchorHashStability::Stable,
2378        );
2379        let unstable = anchor(
2380            AnchorProvenanceClass::Anchored,
2381            Some("h1"),
2382            AnchorHashStability::Unstable,
2383        );
2384        let obs = ArtifactObservation::Present {
2385            current_hash: Some("h2".into()),
2386        };
2387        assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
2388        assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
2389    }
2390
2391    #[test]
2392    fn absent_artifact_is_orphaned() {
2393        let a = anchor(
2394            AnchorProvenanceClass::Anchored,
2395            Some("h1"),
2396            AnchorHashStability::Stable,
2397        );
2398        assert_eq!(
2399            resolve_anchor(&a, &ArtifactObservation::Absent),
2400            AnchorState::Orphaned
2401        );
2402    }
2403
2404    #[test]
2405    fn non_hash_classes_never_drift() {
2406        for class in [
2407            AnchorProvenanceClass::Authored,
2408            AnchorProvenanceClass::InformedBy,
2409        ] {
2410            let a = anchor(class, None, AnchorHashStability::Stable);
2411            // Content moved underneath — still resolves (excluded from
2412            // hash-drift adjudication).
2413            let obs = ArtifactObservation::Present {
2414                current_hash: Some("whatever".into()),
2415            };
2416            assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
2417            // But an absent artifact is still orphaned.
2418            assert_eq!(
2419                resolve_anchor(&a, &ArtifactObservation::Absent),
2420                AnchorState::Orphaned
2421            );
2422        }
2423    }
2424
2425    #[test]
2426    fn unavailable_hash_rechecks_not_drifts() {
2427        let a = anchor(
2428            AnchorProvenanceClass::Anchored,
2429            Some("h1"),
2430            AnchorHashStability::Stable,
2431        );
2432        let obs = ArtifactObservation::Present { current_hash: None };
2433        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
2434    }
2435
2436    // -- composition -------------------------------------------------------
2437
2438    #[test]
2439    fn composition_counts_classes_grains_and_tree_fanout() {
2440        let anchors = vec![
2441            Anchor {
2442                artifact: "a.rs".into(),
2443                grain: AnchorGrain::File,
2444                class: AnchorProvenanceClass::Anchored,
2445                at_version: None,
2446                hash: Some("h".into()),
2447                hash_stability: AnchorHashStability::Stable,
2448                derived_from: Vec::new(),
2449                binding: None,
2450                source: None,
2451                span_unvalidated: false,
2452                hash_source: None,
2453                last_observed: None,
2454            },
2455            Anchor {
2456                artifact: "src/".into(),
2457                grain: AnchorGrain::Tree,
2458                class: AnchorProvenanceClass::Derived,
2459                at_version: None,
2460                hash: Some("t".into()),
2461                hash_stability: AnchorHashStability::Stable,
2462                derived_from: vec!["a.rs".into(), "b.rs".into()],
2463                binding: None,
2464                source: None,
2465                span_unvalidated: false,
2466                hash_source: None,
2467                last_observed: None,
2468            },
2469        ];
2470        let comp = compose_entity_anchors(&anchors);
2471        assert_eq!(comp.by_class["anchored"], 1);
2472        assert_eq!(comp.by_class["derived"], 1);
2473        assert_eq!(comp.by_grain["file"], 1);
2474        assert_eq!(comp.by_grain["tree"], 1);
2475        // Tree fan-out is a distinct axis — one row, never per-file credit.
2476        assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
2477        assert_eq!(
2478            comp.derived_inputs,
2479            vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
2480        );
2481    }
2482
2483    // -- sidecar round-trip -------------------------------------------------
2484
2485    #[test]
2486    fn sidecar_round_trips_and_prunes_empty() {
2487        let mut sc = AnchorSidecar::default();
2488        assert!(sc.is_empty());
2489        let a = anchor(
2490            AnchorProvenanceClass::Anchored,
2491            Some("h1"),
2492            AnchorHashStability::Stable,
2493        );
2494        sc.set("specs--x", vec![a.clone()]);
2495        assert_eq!(sc.get("specs--x").len(), 1);
2496
2497        let bytes = sc.to_bytes();
2498        let round = AnchorSidecar::from_bytes(&bytes).unwrap();
2499        assert_eq!(round, sc);
2500
2501        // Setting empty prunes the key.
2502        sc.set("specs--x", vec![]);
2503        assert!(sc.is_empty());
2504        assert!(sc.get("specs--x").is_empty());
2505    }
2506
2507    // -- merge / unset arithmetic ------------------------------------------
2508
2509    fn file_anchor(artifact: &str, hash: &str) -> Anchor {
2510        Anchor {
2511            artifact: artifact.into(),
2512            grain: AnchorGrain::File,
2513            class: AnchorProvenanceClass::Anchored,
2514            at_version: None,
2515            hash: Some(hash.into()),
2516            hash_stability: AnchorHashStability::Stable,
2517            derived_from: Vec::new(),
2518            binding: None,
2519            source: None,
2520            span_unvalidated: false,
2521            hash_source: None,
2522            last_observed: None,
2523        }
2524    }
2525
2526    /// Merge appends a new triple and leaves the existing set untouched —
2527    /// the incremental-anchoring contract (N existing + 1 new ⇒ N+1).
2528    #[test]
2529    fn merge_appends_new_triple_without_touching_others() {
2530        let mut sc = AnchorSidecar::default();
2531        sc.set(
2532            "m--e",
2533            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2534        );
2535        sc.merge("m--e", &[], vec![file_anchor("c.rs", "h-c")]);
2536        let row = sc.get("m--e");
2537        assert_eq!(row.len(), 3);
2538        assert_eq!(row[0], file_anchor("a.rs", "h-a"));
2539        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2540        assert_eq!(row[2], file_anchor("c.rs", "h-c"));
2541    }
2542
2543    /// An incoming anchor with an existing `(artifact, grain, class)`
2544    /// triple replaces exactly that one, in place; others stay
2545    /// byte-identical.
2546    #[test]
2547    fn merge_replaces_same_triple_in_place() {
2548        let mut sc = AnchorSidecar::default();
2549        sc.set(
2550            "m--e",
2551            vec![file_anchor("a.rs", "h-old"), file_anchor("b.rs", "h-b")],
2552        );
2553        sc.merge("m--e", &[], vec![file_anchor("a.rs", "h-new")]);
2554        let row = sc.get("m--e");
2555        assert_eq!(row.len(), 2);
2556        assert_eq!(row[0], file_anchor("a.rs", "h-new"));
2557        assert_eq!(row[1], file_anchor("b.rs", "h-b"));
2558    }
2559
2560    /// Same artifact under a different grain or class is a different
2561    /// identity — it appends rather than replaces (the triple is the merge
2562    /// key, not the artifact alone).
2563    #[test]
2564    fn merge_treats_grain_and_class_as_identity() {
2565        let mut sc = AnchorSidecar::default();
2566        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2567        let mut span = file_anchor("a.rs", "h-span");
2568        span.grain = AnchorGrain::Span;
2569        let mut informed = file_anchor("a.rs", "h-a");
2570        informed.class = AnchorProvenanceClass::InformedBy;
2571        informed.hash = None;
2572        sc.merge("m--e", &[], vec![span, informed]);
2573        assert_eq!(sc.get("m--e").len(), 3);
2574    }
2575
2576    /// Re-sending an entity's full current set is a no-op on the stored
2577    /// bytes, and merging an empty list changes nothing.
2578    #[test]
2579    fn merge_full_resend_and_empty_are_noops() {
2580        let mut sc = AnchorSidecar::default();
2581        sc.set(
2582            "m--e",
2583            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2584        );
2585        let before = sc.to_bytes();
2586        sc.merge(
2587            "m--e",
2588            &[],
2589            vec![file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")],
2590        );
2591        assert_eq!(sc.to_bytes(), before, "full re-send is byte-stable");
2592        sc.merge("m--e", &[], Vec::new());
2593        assert_eq!(sc.to_bytes(), before, "empty merge is a no-op");
2594    }
2595
2596    /// A bare-artifact unset removes all of that artifact's anchors and
2597    /// nothing else; a grain/class-narrowed unset removes only the match;
2598    /// a selector matching nothing is a no-op.
2599    #[test]
2600    fn unset_selects_by_artifact_with_optional_narrowing() {
2601        let mut span = file_anchor("a.rs", "h-span");
2602        span.grain = AnchorGrain::Span;
2603        let mut sc = AnchorSidecar::default();
2604        sc.set(
2605            "m--e",
2606            vec![
2607                file_anchor("a.rs", "h-a"),
2608                span.clone(),
2609                file_anchor("b.rs", "h-b"),
2610            ],
2611        );
2612
2613        // Narrowed: only the span-grain anchor on a.rs goes.
2614        let narrowed = AnchorUnset {
2615            artifact: "a.rs".into(),
2616            grain: Some(AnchorGrain::Span),
2617            class: None,
2618        };
2619        sc.merge("m--e", &[narrowed], Vec::new());
2620        assert_eq!(
2621            sc.get("m--e"),
2622            &[file_anchor("a.rs", "h-a"), file_anchor("b.rs", "h-b")]
2623        );
2624
2625        // Nonexistent target: idempotent no-op.
2626        let missing = AnchorUnset {
2627            artifact: "never-there.rs".into(),
2628            grain: None,
2629            class: None,
2630        };
2631        sc.merge("m--e", &[missing], Vec::new());
2632        assert_eq!(sc.get("m--e").len(), 2);
2633
2634        // Bare artifact: everything on a.rs goes, b.rs untouched.
2635        let bare = AnchorUnset {
2636            artifact: "a.rs".into(),
2637            grain: None,
2638            class: None,
2639        };
2640        sc.merge("m--e", &[bare], Vec::new());
2641        assert_eq!(sc.get("m--e"), &[file_anchor("b.rs", "h-b")]);
2642    }
2643
2644    /// Unset applies before merge in the same call: unsetting an artifact
2645    /// and writing a new anchor on it lands the new anchor (full-replace
2646    /// stays expressible in one call).
2647    #[test]
2648    fn unset_applies_before_merge() {
2649        let mut span = file_anchor("a.rs", "h-span");
2650        span.grain = AnchorGrain::Span;
2651        let mut sc = AnchorSidecar::default();
2652        sc.set("m--e", vec![file_anchor("a.rs", "h-old"), span]);
2653        let bare = AnchorUnset {
2654            artifact: "a.rs".into(),
2655            grain: None,
2656            class: None,
2657        };
2658        sc.merge("m--e", &[bare], vec![file_anchor("a.rs", "h-new")]);
2659        assert_eq!(sc.get("m--e"), &[file_anchor("a.rs", "h-new")]);
2660    }
2661
2662    /// A row emptied by unsets prunes its key — the sidecar never keeps
2663    /// empty rows.
2664    #[test]
2665    fn merge_prunes_row_emptied_by_unset() {
2666        let mut sc = AnchorSidecar::default();
2667        sc.set("m--e", vec![file_anchor("a.rs", "h-a")]);
2668        let bare = AnchorUnset {
2669            artifact: "a.rs".into(),
2670            grain: None,
2671            class: None,
2672        };
2673        sc.merge("m--e", &[bare], Vec::new());
2674        assert!(sc.is_empty());
2675        assert!(!sc.to_bytes().windows(5).any(|w| w == b"m--e\""));
2676    }
2677
2678    /// The unset validator: artifact required; grain/class, when supplied,
2679    /// must be known wire strings; absent narrowing means "any".
2680    #[test]
2681    fn unset_input_validates_typed() {
2682        let ok = AnchorUnsetInput {
2683            artifact: Some("  a.rs  ".into()),
2684            grain: Some("span".into()),
2685            class: None,
2686        }
2687        .validate()
2688        .unwrap();
2689        assert_eq!(ok.artifact, "a.rs");
2690        assert_eq!(ok.grain, Some(AnchorGrain::Span));
2691        assert_eq!(ok.class, None);
2692
2693        let missing = AnchorUnsetInput::default().validate().unwrap_err();
2694        assert!(matches!(missing, AnchorValidationError::MissingArtifact));
2695        assert_eq!(missing.code(), INVALID_ANCHOR_CODE);
2696
2697        let bad_grain = AnchorUnsetInput {
2698            artifact: Some("a.rs".into()),
2699            grain: Some("paragraph".into()),
2700            class: None,
2701        }
2702        .validate()
2703        .unwrap_err();
2704        assert!(matches!(
2705            bad_grain,
2706            AnchorValidationError::UnknownGrain { .. }
2707        ));
2708
2709        let bad_class = AnchorUnsetInput {
2710            artifact: Some("a.rs".into()),
2711            grain: None,
2712            class: Some("guessed".into()),
2713        }
2714        .validate()
2715        .unwrap_err();
2716        assert!(matches!(
2717            bad_class,
2718            AnchorValidationError::UnknownClass { .. }
2719        ));
2720    }
2721
2722    #[test]
2723    fn sidecar_rename_leaves_zero_rows_under_old_id() {
2724        let mut sc = AnchorSidecar::default();
2725        sc.set(
2726            "specs--old",
2727            vec![anchor(
2728                AnchorProvenanceClass::Anchored,
2729                Some("h"),
2730                AnchorHashStability::Stable,
2731            )],
2732        );
2733        sc.rename("specs--old", "specs--new");
2734        assert!(sc.get("specs--old").is_empty());
2735        assert_eq!(sc.get("specs--new").len(), 1);
2736    }
2737
2738    #[test]
2739    fn sidecar_remove_drops_entity_anchors() {
2740        let mut sc = AnchorSidecar::default();
2741        sc.set(
2742            "specs--gone",
2743            vec![anchor(
2744                AnchorProvenanceClass::Anchored,
2745                Some("h"),
2746                AnchorHashStability::Stable,
2747            )],
2748        );
2749        sc.remove("specs--gone");
2750        assert!(sc.get("specs--gone").is_empty());
2751        // Idempotent.
2752        sc.remove("specs--gone");
2753    }
2754
2755    #[test]
2756    fn empty_bytes_parse_as_empty_sidecar() {
2757        assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
2758        assert!(AnchorSidecar::from_bytes(b"  \n ").unwrap().is_empty());
2759    }
2760
2761    #[test]
2762    fn anchor_json_shape_omits_empty_optionals() {
2763        let a = anchor(
2764            AnchorProvenanceClass::Anchored,
2765            Some("h1"),
2766            AnchorHashStability::Stable,
2767        );
2768        let v = serde_json::to_value(&a).unwrap();
2769        assert_eq!(v["artifact"], "src/lib.rs");
2770        assert_eq!(v["grain"], "file");
2771        assert_eq!(v["class"], "anchored");
2772        assert_eq!(v["hash"], "h1");
2773        assert_eq!(v["hash_stability"], "stable");
2774        // Absent optionals are skipped, not null.
2775        assert!(v.get("at_version").is_none());
2776        assert!(v.get("derived_from").is_none());
2777        assert!(v.get("binding").is_none());
2778    }
2779
2780    #[test]
2781    fn anchor_version_serialises_tagged() {
2782        let a = Anchor {
2783            at_version: Some(AnchorVersion::Commit("deadbeef".into())),
2784            ..anchor(
2785                AnchorProvenanceClass::Anchored,
2786                Some("h"),
2787                AnchorHashStability::Stable,
2788            )
2789        };
2790        let v = serde_json::to_value(&a).unwrap();
2791        assert_eq!(v["at_version"]["kind"], "commit");
2792        assert_eq!(v["at_version"]["value"], "deadbeef");
2793    }
2794
2795    /// `source` rides validation: a non-empty name is carried, absent
2796    /// stays absent, and present-but-empty refuses `INVALID_ANCHOR`
2797    /// with `field: source` in the recovery detail.
2798    #[test]
2799    fn validate_source_carried_absent_or_refused_when_empty() {
2800        let mut input = AnchorInput {
2801            artifact: Some("src/lib.rs".into()),
2802            grain: Some("file".into()),
2803            class: Some("anchored".into()),
2804            ..Default::default()
2805        };
2806        assert_eq!(
2807            input.validate(None).unwrap().source,
2808            None,
2809            "absent stays absent"
2810        );
2811
2812        input.source = Some("  api-docs  ".into());
2813        assert_eq!(
2814            input.validate(None).unwrap().source.as_deref(),
2815            Some("api-docs"),
2816            "non-empty name is carried (trimmed)"
2817        );
2818
2819        input.source = Some("   ".into());
2820        let err = input.validate(None).unwrap_err();
2821        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2822        assert!(matches!(err, AnchorValidationError::EmptySource));
2823        assert_eq!(
2824            err.detail().get("field"),
2825            Some(&serde_json::json!("source"))
2826        );
2827    }
2828
2829    /// A sidecar written before the `source` field existed loads
2830    /// unchanged (additive, optional — no migration, no version bump),
2831    /// and a sourced anchor round-trips through serde.
2832    #[test]
2833    fn source_is_additive_on_the_persisted_shape() {
2834        let pre_plan = r#"{
2835            "artifact": "src/lib.rs",
2836            "grain": "file",
2837            "class": "anchored",
2838            "hash_stability": "stable"
2839        }"#;
2840        let a: Anchor = serde_json::from_str(pre_plan).expect("pre-plan anchor loads");
2841        assert_eq!(a.source, None, "no backfill, no default");
2842
2843        let sourced = Anchor {
2844            source: Some("api-docs".into()),
2845            ..a
2846        };
2847        let json = serde_json::to_string(&sourced).unwrap();
2848        let back: Anchor = serde_json::from_str(&json).unwrap();
2849        assert_eq!(back.source.as_deref(), Some("api-docs"));
2850    }
2851
2852    // --- sidecar version 2, supplied observations, the url namespace rule ---
2853
2854    #[test]
2855    fn sidecar_v1_loads_and_upgrades_in_memory_v3_refuses() {
2856        let v1 = br#"{"version":1,"entities":{"m--e":[{"artifact":"https://x.test/a","grain":"url","class":"informed-by","hash_stability":"unstable"}]}}"#;
2857        let sc = AnchorSidecar::from_bytes(v1).expect("version 1 loads");
2858        assert_eq!(sc.version, ANCHOR_SIDECAR_VERSION, "upgraded in memory");
2859        assert_eq!(sc.get("m--e").len(), 1);
2860        assert!(sc.get("m--e")[0].last_observed.is_none(), "rows unchanged");
2861        let rewritten = String::from_utf8(sc.to_bytes()).unwrap();
2862        assert!(rewritten.contains("\"version\": 2"), "{rewritten}");
2863
2864        let v3 = br#"{"version":3,"entities":{}}"#;
2865        let err = AnchorSidecar::from_bytes(v3).expect_err("unknown higher version refuses");
2866        assert!(
2867            err.to_string()
2868                .contains("unsupported anchors sidecar version 3"),
2869            "{err}"
2870        );
2871    }
2872
2873    #[test]
2874    fn last_observed_round_trips_and_is_absent_when_none() {
2875        let mut a = valid_input().validate(None).unwrap();
2876        let json = serde_json::to_value(&a).unwrap();
2877        assert!(json.get("last_observed").is_none());
2878        a.last_observed = Some(AnchorObservation {
2879            at: "2026-09-01T10:00:00Z".into(),
2880            hash: Some("abc".into()),
2881            state: AnchorState::Resolves,
2882        });
2883        let json = serde_json::to_value(&a).unwrap();
2884        assert_eq!(json["last_observed"]["state"], "resolves");
2885        let back: Anchor = serde_json::from_value(json).unwrap();
2886        assert_eq!(back, a);
2887    }
2888
2889    #[test]
2890    fn url_grain_is_admitted_beside_a_path_medium_and_path_grains_refuse_a_url_artifact() {
2891        let mut i = valid_input();
2892        i.grain = Some("url".into());
2893        i.artifact = Some("https://example.org/doc.pdf".into());
2894        i.class = Some("anchored".into());
2895        i.hash = None;
2896        i.content = Some("the document text".into());
2897        i.hash_stability = None;
2898        let a = i
2899            .validate(Some(("filesystem", "path")))
2900            .expect("url beside a path medium is legal");
2901        assert_eq!(a.grain, AnchorGrain::Url);
2902        assert_eq!(a.hash_source, Some(AnchorHashSource::Author));
2903        assert_eq!(
2904            a.hash_stability,
2905            AnchorHashStability::Unstable,
2906            "url default"
2907        );
2908
2909        for grain in ["span", "file", "tree"] {
2910            let mut i = valid_input();
2911            i.grain = Some(grain.into());
2912            i.artifact = Some("https://example.org/doc.pdf#L1-L3".into());
2913            i.class = Some("informed-by".into());
2914            i.hash = None;
2915            let err = i.validate(Some(("filesystem", "path"))).unwrap_err();
2916            assert!(
2917                matches!(&err, AnchorValidationError::PathGrainOnUrlArtifact { grain: g, .. } if *g == grain),
2918                "{grain}: {err:?}"
2919            );
2920            assert_eq!(err.code(), INVALID_ANCHOR_CODE);
2921            assert!(err.to_string().contains("never enters a path namespace"));
2922        }
2923        assert!(looks_like_url("https://a.b/c"));
2924        assert!(looks_like_url("file://x"));
2925        assert!(!looks_like_url("src/main.rs"));
2926        assert!(!looks_like_url("://nope"));
2927        assert!(!looks_like_url("http://"));
2928    }
2929
2930    #[test]
2931    fn supplied_observations_validate_all_or_nothing() {
2932        let now = "2026-09-02T12:00:00Z";
2933        let rows = vec![
2934            SuppliedObservationInput {
2935                artifact: Some("https://a.test/1".into()),
2936                hash: Some("h1".into()),
2937                ..Default::default()
2938            },
2939            SuppliedObservationInput {
2940                artifact: Some("https://a.test/2".into()),
2941                content: Some("body\r\n".into()),
2942                observed_at: Some("2026-08-01".into()),
2943                ..Default::default()
2944            },
2945            SuppliedObservationInput {
2946                artifact: Some("https://a.test/3".into()),
2947                absent: Some(true),
2948                ..Default::default()
2949            },
2950        ];
2951        let ok = validate_supplied_observations(&rows, now).unwrap();
2952        assert_eq!(ok.len(), 3);
2953        assert_eq!(ok["https://a.test/1"].at, now);
2954        assert_eq!(
2955            ok["https://a.test/2"].outcome,
2956            SuppliedOutcome::Present {
2957                hash: prepared_content_hash(b"body\r\n")
2958            },
2959            "content hashes under the write path's canonicalization"
2960        );
2961        assert_eq!(ok["https://a.test/2"].at, "2026-08-01");
2962        assert_eq!(ok["https://a.test/3"].outcome, SuppliedOutcome::Absent);
2963
2964        // hash + content on one row: ambiguous, refused by row number.
2965        let bad = vec![SuppliedObservationInput {
2966            artifact: Some("https://a.test/1".into()),
2967            hash: Some("h".into()),
2968            content: Some("c".into()),
2969            ..Default::default()
2970        }];
2971        let err = validate_supplied_observations(&bad, now).unwrap_err();
2972        assert!(matches!(
2973            err,
2974            ObservationValidationError::OutcomeAmbiguous { row: 1, .. }
2975        ));
2976        assert_eq!(err.code(), INVALID_OBSERVATION_CODE);
2977        // nothing at all
2978        let bad = vec![SuppliedObservationInput {
2979            artifact: Some("https://a.test/1".into()),
2980            ..Default::default()
2981        }];
2982        assert!(matches!(
2983            validate_supplied_observations(&bad, now).unwrap_err(),
2984            ObservationValidationError::OutcomeAmbiguous { .. }
2985        ));
2986        let bad = vec![SuppliedObservationInput {
2987            artifact: Some("https://a.test/1".into()),
2988            hash: Some("h".into()),
2989            observed_at: Some("yesterday".into()),
2990            ..Default::default()
2991        }];
2992        assert!(matches!(
2993            validate_supplied_observations(&bad, now).unwrap_err(),
2994            ObservationValidationError::BadTimestamp { .. }
2995        ));
2996        let dup = vec![rows[0].clone(), rows[0].clone()];
2997        assert!(matches!(
2998            validate_supplied_observations(&dup, now).unwrap_err(),
2999            ObservationValidationError::DuplicateArtifact {
3000                first: 1,
3001                second: 2,
3002                ..
3003            }
3004        ));
3005        assert!(matches!(
3006            validate_supplied_observations(&[SuppliedObservationInput::default()], now)
3007                .unwrap_err(),
3008            ObservationValidationError::MissingArtifact { row: 1 }
3009        ));
3010    }
3011
3012    #[test]
3013    fn days_between_ages_by_civil_date() {
3014        assert_eq!(days_between("2026-08-01", "2026-09-02T00:00:00Z"), Some(32));
3015        assert_eq!(
3016            days_between("2026-09-02T23:59:59Z", "2026-09-02T00:00:00Z"),
3017            Some(0)
3018        );
3019        assert_eq!(days_between("2026-09-03", "2026-09-02"), Some(0), "floored");
3020        assert_eq!(days_between("garbage", "2026-09-02"), None);
3021        assert_eq!(iso_days_since_epoch("1970-01-01"), Some(0));
3022        assert_eq!(iso_days_since_epoch("2000-03-01"), Some(11017));
3023    }
3024}