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