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}