Skip to main content

memstead_base/
anchor.rs

1//! Anchors — engine-owned durable provenance records tying an entity to
2//! the source artifacts it describes.
3//!
4//! An anchor is the projection pipeline's single new load-bearing
5//! primitive: which artifact (in the medium's own namespace), at which
6//! *grain*, under which *provenance class*, at which medium-typed
7//! *version*, hashed over the **prepared** artifact form (never raw
8//! bytes) where the class carries hash semantics, and the medium's
9//! declared *hash stability* — so an unstable-source hash break resolves
10//! as [`AnchorState::Recheck`], not [`AnchorState::Drifted`].
11//!
12//! ## Naming
13//!
14//! The `Anchor*` family is deliberately distinct from the three
15//! provenance-adjacent type families already in the tree — it never
16//! reuses `Provenance` / `ProvenanceKind` (the mutation-log record in
17//! [`crate::provenance`]), nor `ArchiveProvenance` / `EntityProvenance` /
18//! `History` (the authoring-provenance payload in
19//! [`memstead_schema::archive_provenance`]). Those stay; anchors are a
20//! separate concern (source→entity provenance, not mutation history nor
21//! authoring lineage).
22//!
23//! ## Wire vocabulary (fixed contract)
24//!
25//! - provenance classes: `anchored` / `derived` / `authored` /
26//!   `informed-by`
27//! - grains: `span` / `file` / `tree` / `url` / `entity`
28//! - hash stability: `stable` / `unstable`
29//! - resolution states: `resolves` / `drifted` / `recheck` / `orphaned`
30//!
31//! The Rust identifiers around this vocabulary are the implementer's
32//! choice; the wire strings are the contract and are locked by the
33//! `*_wire_strings_are_stable` tests below.
34//!
35//! ## Storage
36//!
37//! Anchors persist in an engine-owned sidecar on the mem branch under
38//! [`ANCHOR_SIDECAR_PATH`] (`.memstead/anchors.json`) — see
39//! [`AnchorSidecar`]. The sidecar is written only through engine commits
40//! (the [`crate::backend::MemBackend`] sidecar seam); every external
41//! reader already filters the `.memstead/` namespace, so an anchor-only
42//! commit yields no entity deltas and does not participate in `_hash`.
43//!
44//! ## Scope of this module
45//!
46//! Pure value types, wire (de)serialisation, validation (typed
47//! `INVALID_ANCHOR` refusals with recovery detail), and the resolution
48//! model. No storage or IO lives here — the backend seam and the
49//! mutation/CLI wiring consume these types.
50
51use std::collections::BTreeMap;
52
53use serde::{Deserialize, Serialize};
54
55/// Mem-relative path of the engine-owned anchors sidecar on the mem
56/// branch. Lives under the `.memstead/` umbrella every external reader
57/// already treats as non-entity, so an anchor-only commit produces zero
58/// entity deltas.
59pub const ANCHOR_SIDECAR_PATH: &str = ".memstead/anchors.json";
60
61/// Current sidecar document schema version.
62pub const ANCHOR_SIDECAR_VERSION: u32 = 1;
63
64/// Stable typed error code returned when an `anchors[]` element is
65/// malformed. Mirrors the engine's other typed-envelope codes; the whole
66/// mutation refuses and the entity is not written.
67pub const INVALID_ANCHOR_CODE: &str = "INVALID_ANCHOR";
68
69// ---------------------------------------------------------------------------
70// Provenance class
71// ---------------------------------------------------------------------------
72
73/// The epistemic standing of an anchor — how the entity relates to the
74/// artifact it references.
75///
76/// - [`Anchored`](Self::Anchored) — the entity directly reflects specific
77///   artifact content (carries hash semantics).
78/// - [`Derived`](Self::Derived) — the entity was computed/synthesised from
79///   one or more input artifacts (carries hash semantics; lists inputs).
80/// - [`Authored`](Self::Authored) — a human/agent authored the entity with
81///   the artifact in view (no hash semantics; excluded from drift
82///   adjudication).
83/// - [`InformedBy`](Self::InformedBy) — the artifact informed the entity
84///   without a content-fidelity claim (no hash semantics; excluded from
85///   drift adjudication).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum AnchorProvenanceClass {
89    Anchored,
90    Derived,
91    Authored,
92    InformedBy,
93}
94
95impl AnchorProvenanceClass {
96    /// Every wire string, in declaration order — the allowed set a
97    /// refusal echoes for recovery.
98    pub const WIRE_VALUES: &'static [&'static str] =
99        &["anchored", "derived", "authored", "informed-by"];
100
101    /// Stable wire form.
102    pub fn as_wire(&self) -> &'static str {
103        match self {
104            AnchorProvenanceClass::Anchored => "anchored",
105            AnchorProvenanceClass::Derived => "derived",
106            AnchorProvenanceClass::Authored => "authored",
107            AnchorProvenanceClass::InformedBy => "informed-by",
108        }
109    }
110
111    /// Inverse of [`Self::as_wire`]; `None` for an unknown string so the
112    /// validator can refuse it typed rather than misclassify.
113    pub fn from_wire(s: &str) -> Option<Self> {
114        match s {
115            "anchored" => Some(AnchorProvenanceClass::Anchored),
116            "derived" => Some(AnchorProvenanceClass::Derived),
117            "authored" => Some(AnchorProvenanceClass::Authored),
118            "informed-by" => Some(AnchorProvenanceClass::InformedBy),
119            _ => None,
120        }
121    }
122
123    /// Whether this class carries hash semantics. `anchored` and
124    /// `derived` assert content fidelity and participate in hash-drift
125    /// adjudication; `authored` and `informed-by` do not — a content
126    /// change under them produces no drift state, and supplying a hash on
127    /// them is a validation refusal.
128    pub fn is_hash_bearing(&self) -> bool {
129        matches!(
130            self,
131            AnchorProvenanceClass::Anchored | AnchorProvenanceClass::Derived
132        )
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Grain
138// ---------------------------------------------------------------------------
139
140/// The granularity of the artifact reference an anchor carries.
141///
142/// `span` / `file` / `tree` select within a path-shaped namespace; `url`
143/// selects a web resource; `entity` selects another mem's entity. The
144/// medium-capability matrix ([`crate::binding::medium_capabilities`])
145/// decides which grains a given medium's namespace can support — a
146/// mismatch (e.g. `span` on a `url`-namespace medium) refuses typed at
147/// validation.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum AnchorGrain {
151    Span,
152    File,
153    Tree,
154    Url,
155    Entity,
156}
157
158impl AnchorGrain {
159    /// Every wire string, in declaration order.
160    pub const WIRE_VALUES: &'static [&'static str] = &["span", "file", "tree", "url", "entity"];
161
162    /// Stable wire form.
163    pub fn as_wire(&self) -> &'static str {
164        match self {
165            AnchorGrain::Span => "span",
166            AnchorGrain::File => "file",
167            AnchorGrain::Tree => "tree",
168            AnchorGrain::Url => "url",
169            AnchorGrain::Entity => "entity",
170        }
171    }
172
173    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
174    pub fn from_wire(s: &str) -> Option<Self> {
175        match s {
176            "span" => Some(AnchorGrain::Span),
177            "file" => Some(AnchorGrain::File),
178            "tree" => Some(AnchorGrain::Tree),
179            "url" => Some(AnchorGrain::Url),
180            "entity" => Some(AnchorGrain::Entity),
181            _ => None,
182        }
183    }
184
185    /// Whether this grain can be expressed in the medium's declared anchor
186    /// namespace (the `anchor_namespace` string from the E2 capability
187    /// matrix: `path` / `path+commit` / `entity` / `url`).
188    ///
189    /// - `span` / `file` / `tree` require a path-shaped namespace
190    ///   (`path` or `path+commit`);
191    /// - `url` requires the `url` namespace;
192    /// - `entity` requires the `entity` namespace.
193    pub fn supported_by_namespace(&self, anchor_namespace: &str) -> bool {
194        let path_shaped = matches!(anchor_namespace, "path" | "path+commit");
195        match self {
196            AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => path_shaped,
197            AnchorGrain::Url => anchor_namespace == "url",
198            AnchorGrain::Entity => anchor_namespace == "entity",
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Hash stability
205// ---------------------------------------------------------------------------
206
207/// The medium's declared hash stability — whether a change in the
208/// prepared-content hash is a reliable drift signal.
209///
210/// A `stable` medium's hash break resolves [`AnchorState::Drifted`]; an
211/// `unstable` medium's hash break resolves [`AnchorState::Recheck`]
212/// (the hash may have moved for reasons unrelated to the entity's claim,
213/// so the engine flags it for re-examination rather than asserting drift).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "lowercase")]
216pub enum AnchorHashStability {
217    Stable,
218    Unstable,
219}
220
221impl AnchorHashStability {
222    /// Every wire string.
223    pub const WIRE_VALUES: &'static [&'static str] = &["stable", "unstable"];
224
225    /// Stable wire form.
226    pub fn as_wire(&self) -> &'static str {
227        match self {
228            AnchorHashStability::Stable => "stable",
229            AnchorHashStability::Unstable => "unstable",
230        }
231    }
232
233    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
234    pub fn from_wire(s: &str) -> Option<Self> {
235        match s {
236            "stable" => Some(AnchorHashStability::Stable),
237            "unstable" => Some(AnchorHashStability::Unstable),
238            _ => None,
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Medium-typed version
245// ---------------------------------------------------------------------------
246
247/// A medium-typed pinned version the anchor was recorded against.
248///
249/// Which variant applies follows from the medium's namespace: a git /
250/// `path+commit` medium pins a [`Commit`](Self::Commit); a graph / `entity`
251/// medium pins a [`Snapshot`](Self::Snapshot) token; a web / `url` medium
252/// pins an [`Etag`](Self::Etag). A plain `path` medium (mtime change
253/// signal, no retrievable version) records **absent** — represented as
254/// `None` on [`Anchor::at_version`], never a variant here.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
257pub enum AnchorVersion {
258    /// A git commit id (`path+commit` / git namespace).
259    Commit(String),
260    /// A graph snapshot token (`entity` namespace).
261    Snapshot(String),
262    /// A web ETag (`url` namespace).
263    Etag(String),
264}
265
266// ---------------------------------------------------------------------------
267// Anchor
268// ---------------------------------------------------------------------------
269
270/// One durable anchor record: an entity's provenance tie to a single
271/// source artifact.
272///
273/// This is the persisted + read shape. Malformed wire input is refused
274/// upstream via [`AnchorInput::validate`], which produces this strict type
275/// only when every rule holds.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct Anchor {
278    /// Artifact reference in the medium's own namespace — a repo-relative
279    /// path, a `path@commit`, a URL, or an entity id, interpreted per
280    /// [`Self::grain`] and the medium.
281    pub artifact: String,
282    /// The granularity of [`Self::artifact`].
283    pub grain: AnchorGrain,
284    /// The anchor's epistemic standing.
285    pub class: AnchorProvenanceClass,
286    /// The medium-typed pinned version, or `None` when the medium has no
287    /// retrievable version (plain `path` / mtime).
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub at_version: Option<AnchorVersion>,
290    /// Content hash over the **prepared** artifact form (never raw bytes),
291    /// present only when [`Self::class`] carries hash semantics. `None`
292    /// for `authored` / `informed-by`.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub hash: Option<String>,
295    /// The medium's declared hash stability — governs whether a hash break
296    /// resolves `drifted` or `recheck`.
297    pub hash_stability: AnchorHashStability,
298    /// For a `derived` class: the input artifact refs the entity was
299    /// derived from. Empty for every other class.
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub derived_from: Vec<String>,
302    /// `hash(D)` of the binding that produced this anchor (E2), when a
303    /// binding produced it. `None` for a manually-authored anchor with no
304    /// producing binding.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub binding: Option<String>,
307}
308
309// ---------------------------------------------------------------------------
310// Validation
311// ---------------------------------------------------------------------------
312
313/// A permissive wire-shaped anchor element as it arrives on a mutation's
314/// `anchors[]` parameter. All fields are optional / string-typed so an
315/// unknown class or grain surfaces as a typed [`AnchorValidationError`]
316/// with recovery detail rather than an opaque serde failure. Call
317/// [`Self::validate`] to obtain a strict [`Anchor`].
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct AnchorInput {
320    #[serde(default)]
321    pub artifact: Option<String>,
322    #[serde(default)]
323    pub grain: Option<String>,
324    #[serde(default)]
325    pub class: Option<String>,
326    #[serde(default)]
327    pub at_version: Option<AnchorVersion>,
328    #[serde(default)]
329    pub hash: Option<String>,
330    #[serde(default)]
331    pub hash_stability: Option<String>,
332    #[serde(default)]
333    pub derived_from: Option<Vec<String>>,
334    #[serde(default)]
335    pub binding: Option<String>,
336}
337
338/// A typed `INVALID_ANCHOR` refusal. The whole mutation refuses and the
339/// entity is not written; [`Self::detail`] carries the recovery payload
340/// (offending value + allowed set) the agent fixes from.
341#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
342pub enum AnchorValidationError {
343    /// Provenance class is absent or not one of the allowed wire strings.
344    #[error("unknown anchor provenance class {got:?}; allowed: {}", allowed.join(", "))]
345    UnknownClass {
346        got: Option<String>,
347        allowed: &'static [&'static str],
348    },
349    /// Grain is absent or not one of the allowed wire strings.
350    #[error("unknown anchor grain {got:?}; allowed: {}", allowed.join(", "))]
351    UnknownGrain {
352        got: Option<String>,
353        allowed: &'static [&'static str],
354    },
355    /// Hash stability, when supplied, is not an allowed wire string.
356    #[error("unknown anchor hash stability {got:?}; allowed: {}", allowed.join(", "))]
357    UnknownHashStability {
358        got: String,
359        allowed: &'static [&'static str],
360    },
361    /// The artifact reference is missing or empty.
362    #[error("anchor is missing its artifact reference")]
363    MissingArtifact,
364    /// A content hash was supplied on a class that carries no hash
365    /// semantics (`authored` / `informed-by`).
366    #[error("anchor class '{class}' carries no hash semantics — a content hash is not permitted")]
367    HashOnNonHashClass { class: &'static str },
368    /// The grain cannot be expressed in the medium's anchor namespace
369    /// (per the E2 capability matrix), e.g. `span` on a non-path medium.
370    #[error(
371        "anchor grain '{grain}' is unsupported by a '{medium_type}' medium: its \
372         '{anchor_namespace}' namespace does not admit that grain"
373    )]
374    GrainNamespaceUnsupported {
375        grain: &'static str,
376        medium_type: String,
377        anchor_namespace: &'static str,
378    },
379}
380
381impl AnchorValidationError {
382    /// The stable typed code — always [`INVALID_ANCHOR_CODE`].
383    pub fn code(&self) -> &'static str {
384        INVALID_ANCHOR_CODE
385    }
386
387    /// Structured recovery detail for the typed envelope: the offending
388    /// field, its bad value, and the allowed set where one applies.
389    pub fn detail(&self) -> BTreeMap<String, serde_json::Value> {
390        let mut d = BTreeMap::new();
391        match self {
392            AnchorValidationError::UnknownClass { got, allowed } => {
393                d.insert("field".into(), "class".into());
394                d.insert("got".into(), serde_json::json!(got));
395                d.insert("allowed".into(), serde_json::json!(allowed));
396            }
397            AnchorValidationError::UnknownGrain { got, allowed } => {
398                d.insert("field".into(), "grain".into());
399                d.insert("got".into(), serde_json::json!(got));
400                d.insert("allowed".into(), serde_json::json!(allowed));
401            }
402            AnchorValidationError::UnknownHashStability { got, allowed } => {
403                d.insert("field".into(), "hash_stability".into());
404                d.insert("got".into(), serde_json::json!(got));
405                d.insert("allowed".into(), serde_json::json!(allowed));
406            }
407            AnchorValidationError::MissingArtifact => {
408                d.insert("field".into(), "artifact".into());
409            }
410            AnchorValidationError::HashOnNonHashClass { class } => {
411                d.insert("field".into(), "hash".into());
412                d.insert("class".into(), serde_json::json!(class));
413            }
414            AnchorValidationError::GrainNamespaceUnsupported {
415                grain,
416                medium_type,
417                anchor_namespace,
418            } => {
419                d.insert("field".into(), "grain".into());
420                d.insert("grain".into(), serde_json::json!(grain));
421                d.insert("medium_type".into(), serde_json::json!(medium_type));
422                d.insert(
423                    "anchor_namespace".into(),
424                    serde_json::json!(anchor_namespace),
425                );
426            }
427        }
428        d
429    }
430}
431
432impl AnchorInput {
433    /// Validate this wire element into a strict [`Anchor`], or refuse
434    /// typed.
435    ///
436    /// `medium` — the resolving medium's `(type_name, anchor_namespace)`
437    /// pair, when the mutation resolved one. When `Some`, the grain is
438    /// checked against the namespace (the capability-matrix refusal);
439    /// when `None` (no medium context — a manually-authored anchor), the
440    /// namespace check is skipped and only the vocabulary + hash-semantics
441    /// rules apply.
442    ///
443    /// Rules enforced (each a typed [`AnchorValidationError`]):
444    /// - class present and known;
445    /// - grain present and known;
446    /// - artifact reference present and non-empty;
447    /// - a hash is supplied only on a hash-bearing class;
448    /// - hash stability, when supplied, is a known wire string (defaults
449    ///   to `stable` when absent);
450    /// - grain supported by the medium's namespace (when `medium` given).
451    pub fn validate(&self, medium: Option<(&str, &str)>) -> Result<Anchor, AnchorValidationError> {
452        let class = match self
453            .class
454            .as_deref()
455            .and_then(AnchorProvenanceClass::from_wire)
456        {
457            Some(c) => c,
458            None => {
459                return Err(AnchorValidationError::UnknownClass {
460                    got: self.class.clone(),
461                    allowed: AnchorProvenanceClass::WIRE_VALUES,
462                });
463            }
464        };
465        let grain = match self.grain.as_deref().and_then(AnchorGrain::from_wire) {
466            Some(g) => g,
467            None => {
468                return Err(AnchorValidationError::UnknownGrain {
469                    got: self.grain.clone(),
470                    allowed: AnchorGrain::WIRE_VALUES,
471                });
472            }
473        };
474
475        let artifact = self
476            .artifact
477            .as_deref()
478            .map(str::trim)
479            .filter(|s| !s.is_empty())
480            .map(str::to_string)
481            .ok_or(AnchorValidationError::MissingArtifact)?;
482
483        // Hash stability: default `stable` when absent; refuse an unknown
484        // supplied value.
485        let hash_stability = match self.hash_stability.as_deref() {
486            None => AnchorHashStability::Stable,
487            Some(s) => AnchorHashStability::from_wire(s).ok_or_else(|| {
488                AnchorValidationError::UnknownHashStability {
489                    got: s.to_string(),
490                    allowed: AnchorHashStability::WIRE_VALUES,
491                }
492            })?,
493        };
494
495        // A hash is only meaningful on a hash-bearing class.
496        let hash = self
497            .hash
498            .as_deref()
499            .map(str::trim)
500            .filter(|s| !s.is_empty())
501            .map(str::to_string);
502        if hash.is_some() && !class.is_hash_bearing() {
503            return Err(AnchorValidationError::HashOnNonHashClass {
504                class: class.as_wire(),
505            });
506        }
507
508        // Grain must be expressible in the medium's namespace.
509        if let Some((medium_type, namespace)) = medium
510            && !grain.supported_by_namespace(namespace)
511        {
512            // Resolve the namespace to its `&'static str` so the error
513            // carries a stable value even though the input came borrowed.
514            let anchor_namespace = match namespace {
515                "path" => "path",
516                "path+commit" => "path+commit",
517                "entity" => "entity",
518                "url" => "url",
519                _ => "path",
520            };
521            return Err(AnchorValidationError::GrainNamespaceUnsupported {
522                grain: grain.as_wire(),
523                medium_type: medium_type.to_string(),
524                anchor_namespace,
525            });
526        }
527
528        Ok(Anchor {
529            artifact,
530            grain,
531            class,
532            at_version: self.at_version.clone(),
533            hash,
534            hash_stability,
535            derived_from: self.derived_from.clone().unwrap_or_default(),
536            binding: self
537                .binding
538                .as_deref()
539                .map(str::trim)
540                .filter(|s| !s.is_empty())
541                .map(str::to_string),
542        })
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Resolution
548// ---------------------------------------------------------------------------
549
550/// The resolved state of one anchor against the current medium.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "lowercase")]
553pub enum AnchorState {
554    /// The artifact is present and matches (hash equal, or a non-hash
555    /// class whose artifact still exists).
556    Resolves,
557    /// The artifact is present but its prepared-content hash differs and
558    /// the medium is `stable` — a real content drift.
559    Drifted,
560    /// The artifact is present but drift cannot be asserted — the medium
561    /// is `unstable`, or the hash is unavailable on one side. Flagged for
562    /// re-examination, never reported as drift.
563    Recheck,
564    /// The artifact the anchor references is no longer present in the
565    /// medium.
566    Orphaned,
567}
568
569impl AnchorState {
570    /// Stable wire form.
571    pub fn as_wire(&self) -> &'static str {
572        match self {
573            AnchorState::Resolves => "resolves",
574            AnchorState::Drifted => "drifted",
575            AnchorState::Recheck => "recheck",
576            AnchorState::Orphaned => "orphaned",
577        }
578    }
579}
580
581/// What the engine observed about an anchor's artifact when resolving.
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub enum ArtifactObservation {
584    /// The artifact could not be found in the medium.
585    Absent,
586    /// The artifact is present; `current_hash` is its prepared-content
587    /// hash when the medium could compute one (`None` when the medium has
588    /// no hash for it this pass — e.g. enumeration without preparation).
589    Present { current_hash: Option<String> },
590}
591
592/// Resolve one anchor against a current observation, honouring the class's
593/// hash semantics and the medium's declared stability.
594///
595/// - `authored` / `informed-by` are excluded from hash-drift adjudication:
596///   they [`Resolves`](AnchorState::Resolves) as long as the artifact
597///   exists, [`Orphaned`](AnchorState::Orphaned) when it does not — a
598///   content change never produces a drift state for them.
599/// - `anchored` / `derived` compare the recorded prepared-content hash to
600///   the current one: equal ⇒ resolves; different ⇒ `drifted` on a stable
601///   medium, `recheck` on an unstable one; unavailable on either side ⇒
602///   `recheck` (cannot adjudicate).
603pub fn resolve_anchor(anchor: &Anchor, observation: &ArtifactObservation) -> AnchorState {
604    let current_hash = match observation {
605        ArtifactObservation::Absent => return AnchorState::Orphaned,
606        ArtifactObservation::Present { current_hash } => current_hash,
607    };
608    if !anchor.class.is_hash_bearing() {
609        return AnchorState::Resolves;
610    }
611    match (&anchor.hash, current_hash) {
612        (Some(recorded), Some(current)) if recorded == current => AnchorState::Resolves,
613        (Some(_), Some(_)) => match anchor.hash_stability {
614            AnchorHashStability::Stable => AnchorState::Drifted,
615            AnchorHashStability::Unstable => AnchorState::Recheck,
616        },
617        // Missing hash on either side — cannot adjudicate drift.
618        _ => AnchorState::Recheck,
619    }
620}
621
622/// Per-entity provenance-class + grain composition, computed from an
623/// entity's anchor list. Tree-grain fan-out is surfaced distinctly so a
624/// single entity anchored to a large tree is never laundered into
625/// full per-file credit — the count of tree anchors is visible on its own
626/// axis, and downstream (E3b) reads the fan-out counts from resolution.
627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
628pub struct EntityAnchorComposition {
629    /// Anchor count keyed by provenance-class wire string.
630    pub by_class: BTreeMap<String, usize>,
631    /// Anchor count keyed by grain wire string.
632    pub by_grain: BTreeMap<String, usize>,
633    /// The `derived_from` input lists of every `derived` anchor, in
634    /// anchor order — E3b's derived-input provenance.
635    pub derived_inputs: Vec<Vec<String>>,
636    /// Artifact refs of every `tree`-grain anchor — the fan-out axis. A
637    /// tree anchor is one row here regardless of how many files the tree
638    /// contains; the file count is an observation resolution supplies, not
639    /// a credit this composition grants.
640    pub tree_grain_artifacts: Vec<String>,
641}
642
643/// Compose an entity's anchors into class/grain counts, derived inputs,
644/// and the tree-grain fan-out axis.
645pub fn compose_entity_anchors(anchors: &[Anchor]) -> EntityAnchorComposition {
646    let mut comp = EntityAnchorComposition::default();
647    for a in anchors {
648        *comp
649            .by_class
650            .entry(a.class.as_wire().to_string())
651            .or_insert(0) += 1;
652        *comp
653            .by_grain
654            .entry(a.grain.as_wire().to_string())
655            .or_insert(0) += 1;
656        if a.class == AnchorProvenanceClass::Derived {
657            comp.derived_inputs.push(a.derived_from.clone());
658        }
659        if a.grain == AnchorGrain::Tree {
660            comp.tree_grain_artifacts.push(a.artifact.clone());
661        }
662    }
663    comp
664}
665
666// ---------------------------------------------------------------------------
667// Sidecar document
668// ---------------------------------------------------------------------------
669
670/// The engine-owned anchors sidecar document persisted at
671/// [`ANCHOR_SIDECAR_PATH`] on the mem branch: entity id → its anchors.
672///
673/// Written only through engine commits (the [`crate::backend::MemBackend`]
674/// sidecar seam). Rename rewrites the key atomically in the same commit as
675/// the entity move; delete drops the key in the same commit as the entity
676/// delete.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct AnchorSidecar {
679    /// Document schema version.
680    pub version: u32,
681    /// Entity id (`mem--slug`) → its anchors. An entity with no anchors
682    /// carries no key (an empty vec is pruned on write).
683    #[serde(default)]
684    pub entities: BTreeMap<String, Vec<Anchor>>,
685}
686
687impl Default for AnchorSidecar {
688    fn default() -> Self {
689        Self {
690            version: ANCHOR_SIDECAR_VERSION,
691            entities: BTreeMap::new(),
692        }
693    }
694}
695
696impl AnchorSidecar {
697    /// Parse sidecar bytes; an absent/empty payload yields an empty
698    /// document so callers need not special-case a fresh mem.
699    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
700        if bytes.iter().all(u8::is_ascii_whitespace) {
701            return Ok(Self::default());
702        }
703        serde_json::from_slice(bytes)
704    }
705
706    /// Serialise to canonical pretty JSON with a trailing newline —
707    /// diff-friendly on the mem branch.
708    pub fn to_bytes(&self) -> Vec<u8> {
709        let mut s = serde_json::to_string_pretty(self).expect("anchor sidecar serialises");
710        s.push('\n');
711        s.into_bytes()
712    }
713
714    /// The anchors recorded for `entity_id`, or an empty slice.
715    pub fn get(&self, entity_id: &str) -> &[Anchor] {
716        self.entities
717            .get(entity_id)
718            .map(Vec::as_slice)
719            .unwrap_or(&[])
720    }
721
722    /// Replace `entity_id`'s anchors. An empty list prunes the key so the
723    /// sidecar never accumulates empty rows.
724    pub fn set(&mut self, entity_id: &str, anchors: Vec<Anchor>) {
725        if anchors.is_empty() {
726            self.entities.remove(entity_id);
727        } else {
728            self.entities.insert(entity_id.to_string(), anchors);
729        }
730    }
731
732    /// Drop `entity_id`'s anchors entirely (delete leg). Idempotent.
733    pub fn remove(&mut self, entity_id: &str) {
734        self.entities.remove(entity_id);
735    }
736
737    /// Move `from`'s anchors to `to` (rename leg), leaving zero rows under
738    /// the old id. No-op when `from` has no anchors. When `to` already has
739    /// anchors they are overwritten — a rename onto a live id is refused
740    /// upstream, so this is the residual-stub case only.
741    pub fn rename(&mut self, from: &str, to: &str) {
742        if let Some(anchors) = self.entities.remove(from) {
743            self.entities.insert(to.to_string(), anchors);
744        }
745    }
746
747    /// Whether the document holds no anchors for any entity.
748    pub fn is_empty(&self) -> bool {
749        self.entities.is_empty()
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    // -- wire vocabulary is the contract -----------------------------------
758
759    #[test]
760    fn class_wire_strings_are_stable() {
761        assert_eq!(AnchorProvenanceClass::Anchored.as_wire(), "anchored");
762        assert_eq!(AnchorProvenanceClass::Derived.as_wire(), "derived");
763        assert_eq!(AnchorProvenanceClass::Authored.as_wire(), "authored");
764        assert_eq!(AnchorProvenanceClass::InformedBy.as_wire(), "informed-by");
765        for w in AnchorProvenanceClass::WIRE_VALUES {
766            assert_eq!(AnchorProvenanceClass::from_wire(w).unwrap().as_wire(), *w);
767        }
768        assert!(AnchorProvenanceClass::from_wire("bogus").is_none());
769    }
770
771    #[test]
772    fn grain_wire_strings_are_stable() {
773        for w in AnchorGrain::WIRE_VALUES {
774            assert_eq!(AnchorGrain::from_wire(w).unwrap().as_wire(), *w);
775        }
776        assert_eq!(
777            AnchorGrain::WIRE_VALUES,
778            &["span", "file", "tree", "url", "entity"]
779        );
780        assert!(AnchorGrain::from_wire("chunk").is_none());
781    }
782
783    #[test]
784    fn stability_and_state_wire_strings_are_stable() {
785        assert_eq!(AnchorHashStability::Stable.as_wire(), "stable");
786        assert_eq!(AnchorHashStability::Unstable.as_wire(), "unstable");
787        assert_eq!(AnchorState::Resolves.as_wire(), "resolves");
788        assert_eq!(AnchorState::Drifted.as_wire(), "drifted");
789        assert_eq!(AnchorState::Recheck.as_wire(), "recheck");
790        assert_eq!(AnchorState::Orphaned.as_wire(), "orphaned");
791    }
792
793    #[test]
794    fn only_anchored_and_derived_are_hash_bearing() {
795        assert!(AnchorProvenanceClass::Anchored.is_hash_bearing());
796        assert!(AnchorProvenanceClass::Derived.is_hash_bearing());
797        assert!(!AnchorProvenanceClass::Authored.is_hash_bearing());
798        assert!(!AnchorProvenanceClass::InformedBy.is_hash_bearing());
799    }
800
801    // -- grain / namespace matrix ------------------------------------------
802
803    #[test]
804    fn grain_namespace_support_matches_capability_matrix() {
805        // path-shaped grains need path / path+commit.
806        for g in [AnchorGrain::Span, AnchorGrain::File, AnchorGrain::Tree] {
807            assert!(g.supported_by_namespace("path"));
808            assert!(g.supported_by_namespace("path+commit"));
809            assert!(!g.supported_by_namespace("url"));
810            assert!(!g.supported_by_namespace("entity"));
811        }
812        assert!(AnchorGrain::Url.supported_by_namespace("url"));
813        assert!(!AnchorGrain::Url.supported_by_namespace("path"));
814        assert!(AnchorGrain::Entity.supported_by_namespace("entity"));
815        assert!(!AnchorGrain::Entity.supported_by_namespace("path"));
816    }
817
818    // -- validation refusals -----------------------------------------------
819
820    fn valid_input() -> AnchorInput {
821        AnchorInput {
822            artifact: Some("src/lib.rs".into()),
823            grain: Some("file".into()),
824            class: Some("anchored".into()),
825            hash_stability: Some("stable".into()),
826            hash: Some("abc123".into()),
827            ..Default::default()
828        }
829    }
830
831    #[test]
832    fn validate_accepts_a_well_formed_anchor() {
833        let a = valid_input().validate(Some(("codebase", "path"))).unwrap();
834        assert_eq!(a.artifact, "src/lib.rs");
835        assert_eq!(a.grain, AnchorGrain::File);
836        assert_eq!(a.class, AnchorProvenanceClass::Anchored);
837        assert_eq!(a.hash.as_deref(), Some("abc123"));
838        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
839    }
840
841    #[test]
842    fn validate_defaults_hash_stability_to_stable() {
843        let mut i = valid_input();
844        i.hash_stability = None;
845        let a = i.validate(None).unwrap();
846        assert_eq!(a.hash_stability, AnchorHashStability::Stable);
847    }
848
849    #[test]
850    fn validate_refuses_unknown_class() {
851        let mut i = valid_input();
852        i.class = Some("guessed".into());
853        let err = i.validate(None).unwrap_err();
854        assert_eq!(err.code(), INVALID_ANCHOR_CODE);
855        assert!(matches!(err, AnchorValidationError::UnknownClass { .. }));
856        assert_eq!(err.detail()["field"], serde_json::json!("class"));
857    }
858
859    #[test]
860    fn validate_refuses_unknown_grain() {
861        let mut i = valid_input();
862        i.grain = Some("paragraph".into());
863        let err = i.validate(None).unwrap_err();
864        assert!(matches!(err, AnchorValidationError::UnknownGrain { .. }));
865    }
866
867    #[test]
868    fn validate_refuses_missing_artifact() {
869        let mut i = valid_input();
870        i.artifact = Some("   ".into());
871        let err = i.validate(None).unwrap_err();
872        assert!(matches!(err, AnchorValidationError::MissingArtifact));
873        i.artifact = None;
874        assert!(matches!(
875            valid_input_with_artifact(None).validate(None).unwrap_err(),
876            AnchorValidationError::MissingArtifact
877        ));
878        let _ = i;
879    }
880
881    fn valid_input_with_artifact(a: Option<String>) -> AnchorInput {
882        AnchorInput {
883            artifact: a,
884            ..valid_input()
885        }
886    }
887
888    #[test]
889    fn validate_refuses_hash_on_non_hash_class() {
890        let mut i = valid_input();
891        i.class = Some("authored".into());
892        // hash still supplied → refuse
893        let err = i.validate(None).unwrap_err();
894        assert!(matches!(
895            err,
896            AnchorValidationError::HashOnNonHashClass { class: "authored" }
897        ));
898    }
899
900    #[test]
901    fn validate_accepts_non_hash_class_without_hash() {
902        let mut i = valid_input();
903        i.class = Some("informed-by".into());
904        i.hash = None;
905        let a = i.validate(None).unwrap();
906        assert_eq!(a.class, AnchorProvenanceClass::InformedBy);
907        assert!(a.hash.is_none());
908    }
909
910    #[test]
911    fn validate_refuses_grain_unsupported_by_medium_namespace() {
912        // span grain on a web (url namespace) medium.
913        let mut i = valid_input();
914        i.grain = Some("span".into());
915        i.class = Some("authored".into());
916        i.hash = None;
917        let err = i.validate(Some(("web", "url"))).unwrap_err();
918        match err {
919            AnchorValidationError::GrainNamespaceUnsupported {
920                grain,
921                anchor_namespace,
922                ..
923            } => {
924                assert_eq!(grain, "span");
925                assert_eq!(anchor_namespace, "url");
926            }
927            other => panic!("expected GrainNamespaceUnsupported, got {other:?}"),
928        }
929    }
930
931    #[test]
932    fn validate_skips_namespace_check_without_medium_context() {
933        // span grain, no medium → namespace rule not applied.
934        let mut i = valid_input();
935        i.grain = Some("span".into());
936        assert!(i.validate(None).is_ok());
937    }
938
939    // -- resolution --------------------------------------------------------
940
941    fn anchor(
942        class: AnchorProvenanceClass,
943        hash: Option<&str>,
944        stab: AnchorHashStability,
945    ) -> Anchor {
946        Anchor {
947            artifact: "src/lib.rs".into(),
948            grain: AnchorGrain::File,
949            class,
950            at_version: None,
951            hash: hash.map(str::to_string),
952            hash_stability: stab,
953            derived_from: Vec::new(),
954            binding: None,
955        }
956    }
957
958    #[test]
959    fn resolves_when_hash_matches() {
960        let a = anchor(
961            AnchorProvenanceClass::Anchored,
962            Some("h1"),
963            AnchorHashStability::Stable,
964        );
965        let obs = ArtifactObservation::Present {
966            current_hash: Some("h1".into()),
967        };
968        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
969    }
970
971    #[test]
972    fn stable_hash_break_drifts_unstable_rechecks() {
973        let stable = anchor(
974            AnchorProvenanceClass::Anchored,
975            Some("h1"),
976            AnchorHashStability::Stable,
977        );
978        let unstable = anchor(
979            AnchorProvenanceClass::Anchored,
980            Some("h1"),
981            AnchorHashStability::Unstable,
982        );
983        let obs = ArtifactObservation::Present {
984            current_hash: Some("h2".into()),
985        };
986        assert_eq!(resolve_anchor(&stable, &obs), AnchorState::Drifted);
987        assert_eq!(resolve_anchor(&unstable, &obs), AnchorState::Recheck);
988    }
989
990    #[test]
991    fn absent_artifact_is_orphaned() {
992        let a = anchor(
993            AnchorProvenanceClass::Anchored,
994            Some("h1"),
995            AnchorHashStability::Stable,
996        );
997        assert_eq!(
998            resolve_anchor(&a, &ArtifactObservation::Absent),
999            AnchorState::Orphaned
1000        );
1001    }
1002
1003    #[test]
1004    fn non_hash_classes_never_drift() {
1005        for class in [
1006            AnchorProvenanceClass::Authored,
1007            AnchorProvenanceClass::InformedBy,
1008        ] {
1009            let a = anchor(class, None, AnchorHashStability::Stable);
1010            // Content moved underneath — still resolves (excluded from
1011            // hash-drift adjudication).
1012            let obs = ArtifactObservation::Present {
1013                current_hash: Some("whatever".into()),
1014            };
1015            assert_eq!(resolve_anchor(&a, &obs), AnchorState::Resolves);
1016            // But an absent artifact is still orphaned.
1017            assert_eq!(
1018                resolve_anchor(&a, &ArtifactObservation::Absent),
1019                AnchorState::Orphaned
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn unavailable_hash_rechecks_not_drifts() {
1026        let a = anchor(
1027            AnchorProvenanceClass::Anchored,
1028            Some("h1"),
1029            AnchorHashStability::Stable,
1030        );
1031        let obs = ArtifactObservation::Present { current_hash: None };
1032        assert_eq!(resolve_anchor(&a, &obs), AnchorState::Recheck);
1033    }
1034
1035    // -- composition -------------------------------------------------------
1036
1037    #[test]
1038    fn composition_counts_classes_grains_and_tree_fanout() {
1039        let anchors = vec![
1040            Anchor {
1041                artifact: "a.rs".into(),
1042                grain: AnchorGrain::File,
1043                class: AnchorProvenanceClass::Anchored,
1044                at_version: None,
1045                hash: Some("h".into()),
1046                hash_stability: AnchorHashStability::Stable,
1047                derived_from: Vec::new(),
1048                binding: None,
1049            },
1050            Anchor {
1051                artifact: "src/".into(),
1052                grain: AnchorGrain::Tree,
1053                class: AnchorProvenanceClass::Derived,
1054                at_version: None,
1055                hash: Some("t".into()),
1056                hash_stability: AnchorHashStability::Stable,
1057                derived_from: vec!["a.rs".into(), "b.rs".into()],
1058                binding: None,
1059            },
1060        ];
1061        let comp = compose_entity_anchors(&anchors);
1062        assert_eq!(comp.by_class["anchored"], 1);
1063        assert_eq!(comp.by_class["derived"], 1);
1064        assert_eq!(comp.by_grain["file"], 1);
1065        assert_eq!(comp.by_grain["tree"], 1);
1066        // Tree fan-out is a distinct axis — one row, never per-file credit.
1067        assert_eq!(comp.tree_grain_artifacts, vec!["src/".to_string()]);
1068        assert_eq!(
1069            comp.derived_inputs,
1070            vec![vec!["a.rs".to_string(), "b.rs".to_string()]]
1071        );
1072    }
1073
1074    // -- sidecar round-trip -------------------------------------------------
1075
1076    #[test]
1077    fn sidecar_round_trips_and_prunes_empty() {
1078        let mut sc = AnchorSidecar::default();
1079        assert!(sc.is_empty());
1080        let a = anchor(
1081            AnchorProvenanceClass::Anchored,
1082            Some("h1"),
1083            AnchorHashStability::Stable,
1084        );
1085        sc.set("specs--x", vec![a.clone()]);
1086        assert_eq!(sc.get("specs--x").len(), 1);
1087
1088        let bytes = sc.to_bytes();
1089        let round = AnchorSidecar::from_bytes(&bytes).unwrap();
1090        assert_eq!(round, sc);
1091
1092        // Setting empty prunes the key.
1093        sc.set("specs--x", vec![]);
1094        assert!(sc.is_empty());
1095        assert!(sc.get("specs--x").is_empty());
1096    }
1097
1098    #[test]
1099    fn sidecar_rename_leaves_zero_rows_under_old_id() {
1100        let mut sc = AnchorSidecar::default();
1101        sc.set(
1102            "specs--old",
1103            vec![anchor(
1104                AnchorProvenanceClass::Anchored,
1105                Some("h"),
1106                AnchorHashStability::Stable,
1107            )],
1108        );
1109        sc.rename("specs--old", "specs--new");
1110        assert!(sc.get("specs--old").is_empty());
1111        assert_eq!(sc.get("specs--new").len(), 1);
1112    }
1113
1114    #[test]
1115    fn sidecar_remove_drops_entity_anchors() {
1116        let mut sc = AnchorSidecar::default();
1117        sc.set(
1118            "specs--gone",
1119            vec![anchor(
1120                AnchorProvenanceClass::Anchored,
1121                Some("h"),
1122                AnchorHashStability::Stable,
1123            )],
1124        );
1125        sc.remove("specs--gone");
1126        assert!(sc.get("specs--gone").is_empty());
1127        // Idempotent.
1128        sc.remove("specs--gone");
1129    }
1130
1131    #[test]
1132    fn empty_bytes_parse_as_empty_sidecar() {
1133        assert!(AnchorSidecar::from_bytes(b"").unwrap().is_empty());
1134        assert!(AnchorSidecar::from_bytes(b"  \n ").unwrap().is_empty());
1135    }
1136
1137    #[test]
1138    fn anchor_json_shape_omits_empty_optionals() {
1139        let a = anchor(
1140            AnchorProvenanceClass::Anchored,
1141            Some("h1"),
1142            AnchorHashStability::Stable,
1143        );
1144        let v = serde_json::to_value(&a).unwrap();
1145        assert_eq!(v["artifact"], "src/lib.rs");
1146        assert_eq!(v["grain"], "file");
1147        assert_eq!(v["class"], "anchored");
1148        assert_eq!(v["hash"], "h1");
1149        assert_eq!(v["hash_stability"], "stable");
1150        // Absent optionals are skipped, not null.
1151        assert!(v.get("at_version").is_none());
1152        assert!(v.get("derived_from").is_none());
1153        assert!(v.get("binding").is_none());
1154    }
1155
1156    #[test]
1157    fn anchor_version_serialises_tagged() {
1158        let a = Anchor {
1159            at_version: Some(AnchorVersion::Commit("deadbeef".into())),
1160            ..anchor(
1161                AnchorProvenanceClass::Anchored,
1162                Some("h"),
1163                AnchorHashStability::Stable,
1164            )
1165        };
1166        let v = serde_json::to_value(&a).unwrap();
1167        assert_eq!(v["at_version"]["kind"], "commit");
1168        assert_eq!(v["at_version"]["value"], "deadbeef");
1169    }
1170}