Skip to main content

ai_memory/models/
link.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6// Canonical relation spellings duplicated across `from_str` / `as_str`
7// and the reflect / contradiction response keys (#1558 batch 6).
8pub(crate) const REL_CONTRADICTS: &str = "contradicts";
9pub(crate) const REL_REFLECTS_ON: &str = "reflects_on";
10pub(crate) const REL_DERIVES_FROM: &str = "derives_from";
11
12/// v0.7 Track H — attestation level for a `memory_links` row.
13///
14/// H2 (#566) and H3 (#572) already write the three string variants
15/// directly into the `memory_links.attest_level` TEXT column
16/// (`"unsigned"`, `"self_signed"`, `"peer_attested"`). H4 formalises
17/// the enum so the `memory_verify` MCP tool — and any future verifier
18/// surface — can reason in terms of a closed set rather than an
19/// open-ended string.
20///
21/// `#[serde(rename_all = "snake_case")]` keeps the wire shape byte-
22/// identical to what the database column already holds. The
23/// [`AttestLevel::from_str`] / [`AttestLevel::as_str`] helpers exist
24/// because the column is read as a `String` in many call sites that
25/// are not deserialising through serde (e.g. `rusqlite::Row::get`).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum AttestLevel {
29    /// No signature on the row, or no key enrolled for `observed_by` on
30    /// the receiver. Federation back-compat default — unsigned rows
31    /// still land but downstream consumers know they cannot verify.
32    Unsigned,
33    /// Row was signed locally by this writer (H2 outbound path).
34    SelfSigned,
35    /// Row arrived from a peer with a signature that verified against
36    /// the enrolled `observed_by` public key on this host (H3 inbound
37    /// path).
38    PeerAttested,
39    /// v0.7.0 #1389 L4 / RFC-0001 — capture_turn host-signed memory.
40    /// Distinct from `PeerAttested` (which is federation H3 inbound):
41    /// `SignedByPeer` means an out-of-process HOST supplied a
42    /// `host_signature_b64` + `host_pubkey_b64`; the substrate
43    /// verified the signature against
44    /// `AI_MEMORY_L4_HOST_PUBKEY_ALLOWLIST` and the canonical-bytes
45    /// encoding. Used at `src/mcp/tools/capture_turn.rs::556`.
46    /// Closes F-C9 spec-drift (#1430).
47    SignedByPeer,
48    /// v0.7.0 — daemon-signed governance-audit row. Used by
49    /// `crate::governance::audit::sign_with_daemon_key` when a daemon
50    /// keypair is installed and the substrate emits a Custom-action
51    /// refusal row to the signed_events chain. Distinct from
52    /// `SelfSigned` (H2 link-write outbound) — this variant is the
53    /// substrate's OWN signature on its OWN audit emissions, not on
54    /// content the substrate received from a caller. Closes F-C9
55    /// spec-drift (#1430).
56    DaemonSigned,
57}
58
59impl AttestLevel {
60    /// Parse the string form stored in `memory_links.attest_level` /
61    /// `signed_events.attest_level`.
62    ///
63    /// Returns `None` for unknown values so callers can decide whether
64    /// to treat the column as legacy/`unsigned` or surface an error.
65    /// Keeps the unit-of-truth on the database column shape — H2/H3
66    /// already write the canonical lowercase snake_case strings.
67    /// v0.7.0 #1389 L4 + governance-audit additions parse via the
68    /// `signed_by_peer` and `daemon_signed` arms.
69    #[must_use]
70    pub fn from_str(s: &str) -> Option<Self> {
71        match s {
72            "unsigned" => Some(Self::Unsigned),
73            "self_signed" => Some(Self::SelfSigned),
74            "peer_attested" => Some(Self::PeerAttested),
75            "signed_by_peer" => Some(Self::SignedByPeer),
76            "daemon_signed" => Some(Self::DaemonSigned),
77            _ => None,
78        }
79    }
80
81    /// Canonical wire string for this variant. Mirrors the `serde`
82    /// rename_all and the literals every writer (H2/H3/L4/governance-
83    /// audit) already writes to the DB.
84    #[must_use]
85    pub const fn as_str(&self) -> &'static str {
86        match self {
87            Self::Unsigned => "unsigned",
88            Self::SelfSigned => "self_signed",
89            Self::PeerAttested => "peer_attested",
90            Self::SignedByPeer => "signed_by_peer",
91            Self::DaemonSigned => "daemon_signed",
92        }
93    }
94}
95
96impl std::fmt::Display for AttestLevel {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102/// v0.7.0 fix campaign R1-M4 — typed relation closed-set for
103/// `memory_links.relation`. Paired with the SQL-side CHECK constraint
104/// added by the same R1-M4 migration: defense-in-depth so direct-SQL
105/// writers can no longer slip an unknown relation past the Rust
106/// validator.
107///
108/// `#[serde(rename_all = "snake_case")]` keeps the wire shape and the
109/// `memory_links.relation` TEXT column byte-identical to the values
110/// the v0.6.x codebase already writes (`"related_to"`, `"supersedes"`,
111/// `"contradicts"`, `"derived_from"`, `"reflects_on"`, plus the
112/// v0.7.0 WT-1-A addition `"derives_from"` — distinct from
113/// `"derived_from"` as the atomisation-provenance variant). The
114/// [`MemoryLinkRelation::from_str`] / [`MemoryLinkRelation::as_str`]
115/// helpers exist because the column is read as a `String` in many
116/// call sites that are not deserialising through serde (e.g.
117/// `rusqlite::Row::get`).
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum MemoryLinkRelation {
121    /// Generic association. Default for `LinkBody::resolved` and the
122    /// `INSERT` default in the SQL schema.
123    RelatedTo,
124    /// Source supersedes target (newer / authoritative version).
125    Supersedes,
126    /// Source contradicts target (incompatible claims).
127    Contradicts,
128    /// Source is derived from target (consolidation provenance).
129    DerivedFrom,
130    /// Source is a reflection on target (recursive-learning provenance,
131    /// v0.7.0 Task 1/8).
132    ReflectsOn,
133    /// Source is an atomisation derivative of target — the typed,
134    /// signable, federation-safe expression of the structural
135    /// `memories.atom_of` FK introduced in v0.7.0 WT-1-A (schema v36
136    /// sqlite / v35 postgres). Atom row -> parent memory. Participates
137    /// in `find_paths` traversal alongside the other relations.
138    /// Distinct from `DerivedFrom` (consolidation provenance):
139    /// atomisation is a finer-grained, recoverable split that emits
140    /// one `derives_from` edge per atom; consolidation merges several
141    /// memories into one and emits `derived_from` edges from the
142    /// consolidated memory back to each source.
143    DerivesFrom,
144}
145
146impl MemoryLinkRelation {
147    /// Parse the string form stored in `memory_links.relation`.
148    ///
149    /// Returns `None` for unknown values so callers can decide whether
150    /// to reject with a typed error or fall back to a default. The
151    /// canonical strings are the SQL-side CHECK constraint membership
152    /// list — keep this list in sync with the migration.
153    #[must_use]
154    pub fn from_str(s: &str) -> Option<Self> {
155        match s {
156            "related_to" => Some(Self::RelatedTo),
157            "supersedes" => Some(Self::Supersedes),
158            REL_CONTRADICTS => Some(Self::Contradicts),
159            "derived_from" => Some(Self::DerivedFrom),
160            REL_REFLECTS_ON => Some(Self::ReflectsOn),
161            REL_DERIVES_FROM => Some(Self::DerivesFrom),
162            _ => None,
163        }
164    }
165
166    /// Canonical wire string for this variant. Mirrors the `serde`
167    /// rename_all and the literals every existing call site already
168    /// writes to the DB.
169    #[must_use]
170    pub const fn as_str(&self) -> &'static str {
171        match self {
172            Self::RelatedTo => "related_to",
173            Self::Supersedes => "supersedes",
174            Self::Contradicts => REL_CONTRADICTS,
175            Self::DerivedFrom => "derived_from",
176            Self::ReflectsOn => REL_REFLECTS_ON,
177            Self::DerivesFrom => REL_DERIVES_FROM,
178        }
179    }
180
181    /// Canonical default — matches the `DEFAULT 'related_to'` clause
182    /// on `memory_links.relation` in the schema and the fallback in
183    /// `LinkBody::resolved`.
184    #[must_use]
185    pub const fn default_relation() -> Self {
186        Self::RelatedTo
187    }
188
189    /// Total number of `MemoryLinkRelation` variants. SSOT for the
190    /// "ai-memory supports N typed link relations at v0.7.0" narrative
191    /// in CLAUDE.md / README.md / ROADMAP.md / release-notes — adding
192    /// a new variant requires bumping this const AND the [`all()`]
193    /// slice in the same commit, or the parity test pin in
194    /// `tests/memory_link_relation_count_invariant.rs` fails the build.
195    pub const COUNT: usize = 6;
196
197    /// Canonical enumeration of every variant in declaration order
198    /// (`related_to`, `supersedes`, `contradicts`, `derived_from`,
199    /// `reflects_on`, `derives_from`). Use this anywhere external code
200    /// would otherwise hand-roll the list — kg traversal, federation
201    /// peer-handshake, capability advertisement, parity tests. The
202    /// `length == COUNT` invariant is pinned by
203    /// `tests/memory_link_relation_count_invariant.rs`.
204    #[must_use]
205    pub const fn all() -> &'static [Self; Self::COUNT] {
206        &[
207            Self::RelatedTo,
208            Self::Supersedes,
209            Self::Contradicts,
210            Self::DerivedFrom,
211            Self::ReflectsOn,
212            Self::DerivesFrom,
213        ]
214    }
215}
216
217impl std::fmt::Display for MemoryLinkRelation {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223impl Default for MemoryLinkRelation {
224    fn default() -> Self {
225        Self::default_relation()
226    }
227}
228
229impl std::str::FromStr for MemoryLinkRelation {
230    type Err = String;
231
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        Self::from_str(s).ok_or_else(|| {
234            format!(
235                "invalid memory_link relation '{s}' (expected one of: related_to, \
236                 supersedes, contradicts, derived_from, reflects_on, derives_from)"
237            )
238        })
239    }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct MemoryLink {
244    pub source_id: String,
245    pub target_id: String,
246    /// v0.7.0 fix campaign R1-M4 — typed closed set. Round-trips with
247    /// the `memory_links.relation` TEXT column via
248    /// `MemoryLinkRelation::as_str` (write) / `from_str` (read). The
249    /// SQL CHECK constraint added in migration 0023 enforces the same
250    /// membership at the storage layer so direct-SQL writers cannot
251    /// bypass the Rust validator.
252    pub relation: MemoryLinkRelation,
253    pub created_at: String,
254    /// v0.7 H3 — optional 64-byte Ed25519 signature carried over the
255    /// federation wire. `None` for legacy peers (pre-v0.7) that do not
256    /// sign outbound links; receivers in that case land the row with
257    /// `attest_level = "unsigned"`. When `Some`, it is verified against
258    /// the public key associated with `observed_by` before insert.
259    /// `skip_serializing_if` keeps the wire shape byte-identical to
260    /// pre-H3 for unsigned rows so v0.6.x peers continue to deserialize
261    /// without surprise.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub signature: Option<Vec<u8>>,
264    /// v0.7 H3 — agent_id that asserts this link. Mirrors the H2
265    /// `SignableLink.observed_by` field. Required when `signature` is
266    /// `Some` (it is the lookup key for the verifying public key);
267    /// `None` is treated as "no claim" and short-circuits to unsigned.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub observed_by: Option<String>,
270    /// v0.7 H3 — RFC3339 instant the link became true (matches the
271    /// homonymous column in `memory_links`). Part of the signed bundle;
272    /// must round-trip byte-identical with what the sender signed for
273    /// verification to succeed.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub valid_from: Option<String>,
276    /// v0.7 H3 — RFC3339 instant the link was invalidated, or `None` if
277    /// still valid. Part of the signed bundle.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub valid_until: Option<String>,
280    /// v0.7 H4 — attestation level for the row (`"unsigned"`,
281    /// `"self_signed"`, `"peer_attested"`). Populated by readers that
282    /// surface the `memory_links.attest_level` TEXT column (e.g.
283    /// `db::get_links` for the `memory_get_links` MCP tool). Stays
284    /// `None` on constructors that don't go through a DB read — those
285    /// paths still feed `create_link_inbound` which derives the column
286    /// value from the `attest_level: &str` parameter. The
287    /// `skip_serializing_if` keeps the wire shape byte-identical to
288    /// pre-v0.7 federation peers that don't carry the column.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub attest_level: Option<String>,
291}
292
293#[derive(Debug, Deserialize)]
294pub struct LinkBody {
295    /// Canonical name. Aliased by `from` (S82's wire shape).
296    #[serde(default)]
297    pub source_id: Option<String>,
298    /// `from` alias for `source_id`.
299    #[serde(default)]
300    pub from: Option<String>,
301    /// Canonical name. Aliased by `to` (S82's wire shape).
302    #[serde(default)]
303    pub target_id: Option<String>,
304    /// `to` alias for `target_id`.
305    #[serde(default)]
306    pub to: Option<String>,
307    /// Canonical name. Aliased by `rel_type` (S82's wire shape).
308    #[serde(default)]
309    pub relation: Option<String>,
310    /// `rel_type` alias for `relation`.
311    #[serde(default)]
312    pub rel_type: Option<String>,
313}
314
315impl LinkBody {
316    /// Resolve the canonical (source_id, target_id, relation) tuple
317    /// from the canonical fields or their aliases. Defaults relation
318    /// to `related_to` when neither field is supplied.
319    #[must_use]
320    pub fn resolved(&self) -> (String, String, String) {
321        let s = self
322            .source_id
323            .clone()
324            .or_else(|| self.from.clone())
325            .unwrap_or_default();
326        let t = self
327            .target_id
328            .clone()
329            .or_else(|| self.to.clone())
330            .unwrap_or_default();
331        let r = self
332            .relation
333            .clone()
334            .or_else(|| self.rel_type.clone())
335            .unwrap_or_else(default_relation);
336        (s, t, r)
337    }
338}
339
340fn default_relation() -> String {
341    MemoryLinkRelation::RelatedTo.as_str().to_string()
342}
343
344/// Tag stamped on entity-typed memories so `(title, namespace)` can be
345/// shared across regular memories and entities without ambiguity (Pillar
346/// 2 / Stream B).
347pub const ENTITY_TAG: &str = "entity";
348
349/// Marker written to `metadata.kind` on entity-typed memories. The
350/// db layer keys entity lookups off this field so the alias resolver
351/// never returns a regular memory that happens to share a title with an
352/// entity registered later.
353pub const ENTITY_KIND: &str = "entity";
354
355/// Resolved entity record returned by `db::entity_get_by_alias` and
356/// embedded in the `db::entity_register` response (Pillar 2 / Stream B).
357/// `aliases` is the full alias set for the entity, ordered by
358/// `created_at ASC, alias ASC` for stable display.
359#[derive(Debug, Clone, Serialize)]
360pub struct EntityRecord {
361    pub entity_id: String,
362    pub canonical_name: String,
363    pub namespace: String,
364    pub aliases: Vec<String>,
365}
366
367/// Outcome of `db::entity_register`. `created` is `true` when a new
368/// entity memory was inserted, `false` when an existing entity was
369/// reused (idempotent re-registration that just merged new aliases into
370/// the existing record).
371#[derive(Debug, Clone, Serialize)]
372pub struct EntityRegistration {
373    pub entity_id: String,
374    pub canonical_name: String,
375    pub namespace: String,
376    pub aliases: Vec<String>,
377    pub created: bool,
378}
379
380/// Single row returned by `db::kg_timeline` (Pillar 2 / Stream C).
381///
382/// Captures one outbound assertion from a source memory: the
383/// `target_id` and its `relation`, the temporal-validity window
384/// (`valid_from` / `valid_until`), the agent that observed it
385/// (`observed_by`), and the target's display fields (`title`,
386/// `target_namespace`) for caller convenience. `valid_from` is the
387/// authoritative ordering key — events with NULL `valid_from` are
388/// excluded from the timeline by the query.
389#[derive(Debug, Clone, Serialize)]
390pub struct KgTimelineEvent {
391    pub target_id: String,
392    pub relation: String,
393    pub valid_from: String,
394    pub valid_until: Option<String>,
395    pub observed_by: Option<String>,
396    pub title: String,
397    pub target_namespace: String,
398}
399
400/// One node returned by `db::kg_query` (Pillar 2 / Stream C —
401/// `memory_kg_query`). Each node represents a memory reachable from the
402/// query's source through one outbound link, carrying the link's
403/// temporal-validity columns plus the target memory's display fields and
404/// the traversal path. `depth` is the actual number of hops from the
405/// source (1..=`KG_QUERY_MAX_SUPPORTED_DEPTH`); `path` is the
406/// `src->mid->target` chain as discovered by the recursive CTE.
407#[derive(Debug, Clone, Serialize)]
408pub struct KgQueryNode {
409    pub target_id: String,
410    pub relation: String,
411    pub valid_from: Option<String>,
412    pub valid_until: Option<String>,
413    pub observed_by: Option<String>,
414    pub title: String,
415    pub target_namespace: String,
416    pub depth: usize,
417    pub path: String,
418}
419
420/// One nearest-neighbor result from a `memory_check_duplicate` lookup
421/// (Pillar 2 / Stream D). `similarity` is the cosine similarity in
422/// `[-1.0, 1.0]`, rounded to three decimals at the response layer.
423#[derive(Debug, Clone, Serialize)]
424pub struct DuplicateMatch {
425    pub id: String,
426    pub title: String,
427    pub namespace: String,
428    pub similarity: f32,
429}
430
431/// Result envelope returned by `db::check_duplicate`.
432///
433/// `is_duplicate` is `nearest.similarity >= threshold`. `nearest` is
434/// `None` only when the candidate pool is empty (no embedded, live
435/// memories matched the namespace filter). When `is_duplicate` is true,
436/// `nearest.id` doubles as the suggested merge target — we surface it
437/// under that name in the JSON response so the contract stays explicit.
438#[derive(Debug, Clone, Serialize)]
439pub struct DuplicateCheck {
440    pub is_duplicate: bool,
441    pub threshold: f32,
442    pub nearest: Option<DuplicateMatch>,
443    pub candidates_scanned: usize,
444}
445
446/// One node of the hierarchical namespace tree returned by
447/// `memory_get_taxonomy` (Pillar 1 / Stream A).
448///
449/// `count` is the number of memories at *exactly* this namespace;
450/// `subtree_count` is the count of memories at this node plus every
451/// descendant the depth limit allowed us to expand. Children are sorted
452/// alphabetically by `name` so callers get a stable rendering order.
453#[derive(Debug, Clone, Serialize)]
454pub struct TaxonomyNode {
455    /// Full namespace path of this node. Empty string for the synthetic
456    /// root when no `namespace_prefix` is supplied.
457    pub namespace: String,
458    /// Last `/`-delimited segment of `namespace` (display label). Empty
459    /// for the synthetic root.
460    pub name: String,
461    /// Memories whose namespace equals this node's `namespace`.
462    pub count: usize,
463    /// Memories at this node plus all descendants visible within the
464    /// requested `depth`. Memories beneath the depth cutoff still
465    /// contribute to the `subtree_count` of the boundary ancestor.
466    pub subtree_count: usize,
467    /// Direct child nodes, sorted alphabetically by `name`.
468    pub children: Vec<TaxonomyNode>,
469}
470
471/// Result envelope returned by `db::get_taxonomy`.
472///
473/// `total_count` is the global memory count for the prefix (independent
474/// of `depth`/`limit` truncation) so callers can render an honest
475/// "X memories in N namespaces" header even when the tree was
476/// truncated. `truncated` is set when the `limit` parameter forced us
477/// to drop input rows when assembling the tree.
478#[derive(Debug, Clone, Serialize)]
479pub struct Taxonomy {
480    pub tree: TaxonomyNode,
481    pub total_count: usize,
482    pub truncated: bool,
483}
484
485/// Phase 3 foundation (issue #224): vector clock tracking the latest
486/// `updated_at` this peer has seen from each known remote peer.
487///
488/// Entries are populated lazily — both on HTTP `/sync/push` (receiver
489/// records the sender's latest `updated_at`) and on HTTP `/sync/since`
490/// (sender advances `last_pulled_at`). Full CRDT-lite merge rules using
491/// the clock are **not** in the v0.6.0 GA foundation; they land in a
492/// follow-up PR under issue #224 Task 3a.1. The foundation ships the
493/// wire format so adding the merge semantics later does not force a
494/// schema migration.
495#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
496pub struct VectorClock {
497    /// Map of peer `agent_id` -> latest RFC3339 `updated_at` seen from
498    /// that peer. A peer absent from the map is equivalent to
499    /// "never-seen-anything." Encoded as a JSON object on the wire.
500    #[serde(default)]
501    pub entries: std::collections::BTreeMap<String, String>,
502}
503
504impl VectorClock {
505    /// Advance this clock to include `peer_id`'s latest seen timestamp.
506    /// Monotonic — an older timestamp never overwrites a newer one.
507    #[allow(dead_code)] // Consumed by Task 3a.1 CRDT-lite merge (issue #224).
508    pub fn observe(&mut self, peer_id: &str, at: &str) {
509        self.entries
510            .entry(peer_id.to_string())
511            .and_modify(|existing| {
512                if at > existing.as_str() {
513                    *existing = at.to_string();
514                }
515            })
516            .or_insert_with(|| at.to_string());
517    }
518
519    /// Look up the latest timestamp this clock has from `peer_id`.
520    #[must_use]
521    #[allow(dead_code)] // Consumed by Task 3a.1 CRDT-lite merge (issue #224).
522    pub fn latest_from(&self, peer_id: &str) -> Option<&str> {
523        self.entries.get(peer_id).map(String::as_str)
524    }
525}
526
527/// Phase 3 foundation: one row of the `sync_state` table serialised for
528/// diagnostic / API responses.
529#[allow(dead_code)] // Consumed by Task 3b.2 sync diagnostics API (issue #224).
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct SyncStateEntry {
532    pub agent_id: String,
533    pub peer_id: String,
534    pub last_seen_at: String,
535    pub last_pulled_at: String,
536}
537
538// -----------------------------------------------------------------
539// L0.7-2 Tier A — LinkBody alias + AttestLevel + VectorClock coverage
540// -----------------------------------------------------------------
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    fn parse_link_body(json: serde_json::Value) -> LinkBody {
546        serde_json::from_value(json).expect("LinkBody deserialises")
547    }
548
549    #[test]
550    fn link_body_resolved_uses_canonical_fields_when_present() {
551        let b = parse_link_body(serde_json::json!({
552            "source_id": "src",
553            "target_id": "tgt",
554            "relation": "supersedes",
555        }));
556        let (s, t, r) = b.resolved();
557        assert_eq!(s, "src");
558        assert_eq!(t, "tgt");
559        assert_eq!(r, "supersedes");
560    }
561
562    #[test]
563    fn link_body_resolved_falls_back_to_from_alias() {
564        // Line 135: from-alias path for source_id
565        let b = parse_link_body(serde_json::json!({
566            "from": "from-id",
567            "to": "to-id",
568            "rel_type": "contradicts",
569        }));
570        let (s, t, r) = b.resolved();
571        assert_eq!(s, "from-id");
572        assert_eq!(t, "to-id");
573        assert_eq!(r, "contradicts");
574    }
575
576    #[test]
577    fn link_body_resolved_defaults_relation_to_related_to() {
578        // Lines 145, 151-153: default_relation invoked when neither
579        // `relation` nor `rel_type` set.
580        let b = parse_link_body(serde_json::json!({
581            "source_id": "a",
582            "target_id": "b",
583        }));
584        let (_s, _t, r) = b.resolved();
585        assert_eq!(r, "related_to");
586    }
587
588    #[test]
589    fn link_body_resolved_empty_payload_returns_empty_strings_and_default() {
590        let b = parse_link_body(serde_json::json!({}));
591        let (s, t, r) = b.resolved();
592        assert_eq!(s, "");
593        assert_eq!(t, "");
594        assert_eq!(r, "related_to");
595    }
596
597    #[test]
598    fn link_body_resolved_canonical_wins_over_alias() {
599        // When BOTH canonical and alias are set, the canonical wins.
600        let b = parse_link_body(serde_json::json!({
601            "source_id": "canonical-src",
602            "from": "alias-src",
603            "target_id": "canonical-tgt",
604            "to": "alias-tgt",
605            "relation": "canonical-rel",
606            "rel_type": "alias-rel",
607        }));
608        let (s, t, r) = b.resolved();
609        assert_eq!(s, "canonical-src");
610        assert_eq!(t, "canonical-tgt");
611        assert_eq!(r, "canonical-rel");
612    }
613
614    #[test]
615    fn attest_level_round_trips_strings() {
616        for (s, v) in [
617            ("unsigned", AttestLevel::Unsigned),
618            ("self_signed", AttestLevel::SelfSigned),
619            ("peer_attested", AttestLevel::PeerAttested),
620        ] {
621            assert_eq!(AttestLevel::from_str(s), Some(v));
622            assert_eq!(v.as_str(), s);
623            assert_eq!(format!("{v}"), s);
624        }
625    }
626
627    #[test]
628    fn attest_level_from_str_returns_none_for_unknown() {
629        assert_eq!(AttestLevel::from_str("unknown"), None);
630        assert_eq!(AttestLevel::from_str(""), None);
631    }
632
633    #[test]
634    fn vector_clock_observe_advances_monotonically() {
635        let mut c = VectorClock::default();
636        c.observe("peer-a", "2026-01-01T00:00:00Z");
637        assert_eq!(c.latest_from("peer-a"), Some("2026-01-01T00:00:00Z"));
638        // Later timestamp must replace.
639        c.observe("peer-a", "2026-02-01T00:00:00Z");
640        assert_eq!(c.latest_from("peer-a"), Some("2026-02-01T00:00:00Z"));
641        // Earlier timestamp must NOT replace.
642        c.observe("peer-a", "2025-12-01T00:00:00Z");
643        assert_eq!(c.latest_from("peer-a"), Some("2026-02-01T00:00:00Z"));
644    }
645
646    #[test]
647    fn vector_clock_latest_from_unknown_peer_is_none() {
648        let c = VectorClock::default();
649        assert_eq!(c.latest_from("never-seen"), None);
650    }
651
652    #[test]
653    fn vector_clock_serializes_as_object_with_entries() {
654        let mut c = VectorClock::default();
655        c.observe("peer-a", "2026-01-01T00:00:00Z");
656        let json = serde_json::to_value(&c).unwrap();
657        assert!(json.get("entries").is_some());
658        assert_eq!(
659            json["entries"]["peer-a"],
660            serde_json::Value::String("2026-01-01T00:00:00Z".to_string())
661        );
662    }
663
664    // ---- C-5 (#699): lift coverage on MemoryLinkRelation parsing/defaults.
665    // Targets uncovered: `MemoryLinkRelation::from_str` unknown branch,
666    // `default_relation`, `Default::default`, `FromStr` wrapper. ----
667
668    #[test]
669    fn memory_link_relation_from_str_returns_none_for_unknown() {
670        // Line 116: `_ => None` arm of the inherent from_str.
671        assert_eq!(MemoryLinkRelation::from_str("bogus"), None);
672        assert_eq!(MemoryLinkRelation::from_str(""), None);
673        assert_eq!(MemoryLinkRelation::from_str("RELATED_TO"), None);
674    }
675
676    #[test]
677    fn memory_link_relation_default_relation_is_related_to() {
678        // Lines 138-140: `default_relation()` associated function.
679        let d = MemoryLinkRelation::default_relation();
680        assert_eq!(d, MemoryLinkRelation::RelatedTo);
681        assert_eq!(d.as_str(), "related_to");
682    }
683
684    #[test]
685    fn memory_link_relation_default_trait_uses_related_to() {
686        // Lines 150-152: `Default::default()` implementation.
687        let d: MemoryLinkRelation = Default::default();
688        assert_eq!(d, MemoryLinkRelation::RelatedTo);
689    }
690
691    #[test]
692    fn memory_link_relation_from_str_trait_round_trips_canonical_strings() {
693        // Lines 158-165: `std::str::FromStr::from_str` wrapper.
694        for (s, v) in [
695            ("related_to", MemoryLinkRelation::RelatedTo),
696            ("supersedes", MemoryLinkRelation::Supersedes),
697            ("contradicts", MemoryLinkRelation::Contradicts),
698            ("derived_from", MemoryLinkRelation::DerivedFrom),
699            ("reflects_on", MemoryLinkRelation::ReflectsOn),
700            ("derives_from", MemoryLinkRelation::DerivesFrom),
701        ] {
702            // Disambiguate against the inherent `from_str` (which returns
703            // Option) by going through the `FromStr` trait fully qualified.
704            let parsed: MemoryLinkRelation =
705                <MemoryLinkRelation as std::str::FromStr>::from_str(s).unwrap();
706            assert_eq!(parsed, v);
707            // Display impl round-trip.
708            assert_eq!(format!("{v}"), s);
709        }
710    }
711
712    #[test]
713    fn memory_link_relation_from_str_trait_returns_helpful_error_for_unknown() {
714        // Lines 158-165: error arm of the FromStr wrapper.
715        let err = <MemoryLinkRelation as std::str::FromStr>::from_str("nope").unwrap_err();
716        assert!(err.contains("nope"));
717        assert!(err.contains("related_to"));
718        assert!(err.contains("reflects_on"));
719    }
720}