Skip to main content

ai_memory/
validate.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{Result, bail};
5
6use crate::models::{
7    Citation, CreateMemory, MAX_CONTENT_SIZE, MAX_NAMESPACE_DEPTH, Memory, SourceSpan,
8    UpdateMemory, VALID_AGENT_TYPES, VALID_SCOPES,
9};
10
11const MAX_TITLE_LEN: usize = 512;
12/// Max characters in a namespace string (post-Task 1.4).
13/// Flat namespaces still fit in the historical 128 budget; 512 is the ceiling
14/// for hierarchical paths like `a/b/c/…` up to 8 levels deep.
15const MAX_NAMESPACE_LEN: usize = 512;
16const MAX_SOURCE_LEN: usize = 64;
17const MAX_TAG_LEN: usize = 128;
18const MAX_TAGS_COUNT: usize = 50;
19const MAX_RELATION_LEN: usize = 64;
20const MAX_ID_LEN: usize = 128;
21const MAX_AGENT_ID_LEN: usize = 128;
22/// Max characters in a wire-supplied base64 Ed25519 public key. A raw
23/// 32-byte key is 44 chars padded / 43 unpadded; 128 leaves generous
24/// slack for whitespace and either base64 flavor while bounding the
25/// decode work a hostile caller can force (#626 Layer-3 attestation).
26const MAX_AGENT_PUBKEY_B64_LEN: usize = 128;
27const MAX_METADATA_SIZE: usize = 65_536;
28const MAX_METADATA_DEPTH: usize = 32;
29
30/// Canonical role-categorical source values accepted by the substrate.
31///
32/// **v0.7.x (issue #1175) — heterogeneous-NHI design:** the substrate is
33/// LLM-vendor-agnostic by design. The role-categorical values describe
34/// **who in the system minted the row** (user / api caller / hook /
35/// cli / etc.), NOT which LLM-vendor backed the AI NHI behind the call.
36/// Vendor identity belongs in `metadata.agent_id` (via the
37/// `host:`/`ai:<client>@<host>:pid-<pid>` resolution ladder), where it
38/// composes with the agent-action substrate without leaking vendor
39/// names into the closed role-categorical enum.
40///
41/// **`"nhi"` (v0.7.x):** the canonical vendor-neutral source value for
42/// reflections / memories minted by an AI Non-Human Identity. Replaces
43/// the pre-#1175 default of `"claude"` (which singled out one vendor
44/// against the substrate's heterogeneous-NHI principle established by
45/// #1067). New substrate writes stamp `"nhi"`; pre-existing rows with
46/// `source = "claude"` continue to be accepted by the validator for
47/// back-compat (see entry below).
48///
49/// **`"claude"` (deprecated, back-compat only):** retained in this
50/// allowlist so legacy rows + tests written before #1175 continue to
51/// validate. Removal scheduled for v0.8.x once operators have had a
52/// migration window. New writes that hardcode this value should be
53/// caught by the per-issue lint added in #1174 PR #10.
54pub(crate) const VALID_SOURCES: &[&str] = &[
55    "user",
56    // v0.7.x (#1175) — vendor-neutral substrate default for AI NHI writes.
57    "nhi",
58    // v0.7.x (#1175) — deprecated, back-compat only; remove in v0.8.x.
59    "claude",
60    "hook",
61    "api",
62    "cli",
63    "import",
64    "consolidation",
65    "system",
66    "chaos",
67    // v0.6.2 (S32): `handle_notify` stamps source="notify" on inbox rows.
68    // Without this entry, peers reject the notify in `sync_push`'s
69    // `validate_memory` — the notify lands on the sender's inbox but
70    // never reaches the target's inbox on peer nodes.
71    "notify",
72];
73
74/// v0.7.x (issue #1175) — the canonical vendor-neutral substrate
75/// default for `source` on AI-NHI-minted rows. Use this constant at
76/// every substrate write site that previously hardcoded `"claude"`.
77///
78/// **Why:** the substrate is heterogeneous-NHI by design (per #1067 +
79/// the v0.7.0 reflection-boundary-is-LLM-agnostic property). Stamping
80/// a single vendor's name on every reflection — regardless of which
81/// AI NHI made the call — is a monoculture defect: forensic queries
82/// keyed on `source = 'claude'` silently miss every row minted by an
83/// OpenAI / xAI / Anthropic / Gemini / DeepSeek / Groq / etc. NHI.
84///
85/// **Migration:** pre-existing rows with `source = "claude"` are
86/// untouched. New substrate writes stamp `DEFAULT_NHI_SOURCE`. Tests
87/// that pass `source = "claude"` continue to validate (the validator
88/// accepts both for back-compat). Removal of the `"claude"` allowlist
89/// arm is scheduled for v0.8.x.
90pub const DEFAULT_NHI_SOURCE: &str = "nhi";
91// Canonical relation taxonomy. The validator (`validate_relation`) accepts
92// these names via the fast-path branch and also accepts any caller-supplied
93// `[a-z0-9_]+` identifier via the lenient branch (post-cb92998). Adding a
94// name here is therefore documentation-driven: the name becomes part of the
95// MCP `memory_link` schema's `enum`, the wire-shape advertised to peers,
96// and the closed set surfaced in CLI/API docs.
97//
98// Semantics of each relation (directionality reads left-to-right, source → target):
99//   * `related_to`   — symmetric association; no provenance claim.
100//   * `supersedes`   — winner → loser; the source replaces the target.
101//   * `contradicts`  — asserts the source contradicts the target.
102//   * `derived_from` — clone/summary (source) → original (target). `derived_from`
103//                      is written by `memory_consolidate` (consolidated → each
104//                      source) and `memory_promote --to-namespace` (clone →
105//                      source). The arrow points FROM the derived memory TO
106//                      the original.
107//   * `reflects_on`  — v0.7.0 Task 3/8 (recursive learning). reflection
108//                      memory (source) → source memory it reflects on
109//                      (target). Mirrors the `derived_from` convention: the
110//                      newer/derived row is the link's `source_id`; the
111//                      thing it points back to is the `target_id`. The
112//                      reflection memory is the one with `reflection_depth
113//                      > 0` (see Memory.reflection_depth, Task 1/8). Task
114//                      4/8 (`memory_reflect` MCP tool) will write these
115//                      links from a reflection memory to each source it
116//                      reflects on. `reflects_on` participates in
117//                      `find_paths` traversal naturally because that BFS
118//                      walks `memory_links` without filtering by relation
119//                      label — operators tracing reflection chains see them
120//                      surface alongside the other relations.
121const VALID_RELATIONS: &[&str] = &[
122    crate::models::MemoryLinkRelation::RelatedTo.as_str(),
123    crate::models::MemoryLinkRelation::Supersedes.as_str(),
124    crate::models::MemoryLinkRelation::Contradicts.as_str(),
125    crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
126    crate::models::MemoryLinkRelation::ReflectsOn.as_str(),
127    // v0.7.0 WT-1-A — atomisation-provenance edge (atom -> parent). The
128    // typed, signable, federation-safe expression of the structural
129    // `memories.atom_of` FK. Distinct from `derived_from` (consolidation
130    // provenance).
131    crate::models::MemoryLinkRelation::DerivesFrom.as_str(),
132];
133
134fn is_valid_rfc3339(s: &str) -> bool {
135    chrono::DateTime::parse_from_rfc3339(s).is_ok()
136}
137
138fn is_clean_string(s: &str) -> bool {
139    !s.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
140}
141
142pub fn validate_title(title: &str) -> Result<()> {
143    let trimmed = title.trim();
144    if trimmed.is_empty() {
145        bail!("title cannot be empty");
146    }
147    if trimmed.chars().count() > MAX_TITLE_LEN {
148        bail!("title exceeds max length of {MAX_TITLE_LEN} characters");
149    }
150    if !is_clean_string(trimmed) {
151        bail!("title contains invalid characters");
152    }
153    Ok(())
154}
155
156pub fn validate_content(content: &str) -> Result<()> {
157    if content.trim().is_empty() {
158        bail!("content cannot be empty");
159    }
160    if content.len() > MAX_CONTENT_SIZE {
161        bail!("content exceeds max size of {MAX_CONTENT_SIZE} bytes");
162    }
163    if !is_clean_string(content) {
164        bail!("content contains invalid characters");
165    }
166    Ok(())
167}
168
169/// Validate a namespace (flat or hierarchical, Task 1.4).
170///
171/// Flat namespaces (`"global"`, `"ai-memory"`) remain fully valid — hierarchy
172/// is opt-in. Hierarchical paths use `/` as the segment delimiter:
173///
174/// ```text
175/// alphaone/engineering/platform
176/// ```
177///
178/// Rules:
179/// - **Not empty**, no leading/trailing whitespace
180/// - Length ≤ [`MAX_NAMESPACE_LEN`] (512 chars)
181/// - Depth (segment count) ≤ [`MAX_NAMESPACE_DEPTH`] (8)
182/// - Backslashes, null bytes, control chars, and spaces are forbidden
183/// - Leading and trailing `/` are forbidden (normalize input via
184///   [`normalize_namespace`] before validating)
185/// - Empty segments (consecutive `//`) are forbidden
186/// - Each segment is non-empty; no further character restriction beyond
187///   the whole-string checks above (preserving historical flexibility
188///   for existing flat namespaces like `ai-memory-mcp-dev`)
189pub fn validate_namespace(ns: &str) -> Result<()> {
190    let trimmed = ns.trim();
191    if trimmed.is_empty() {
192        bail!("namespace cannot be empty");
193    }
194    if trimmed.chars().count() > MAX_NAMESPACE_LEN {
195        bail!("namespace exceeds max length of {MAX_NAMESPACE_LEN} characters");
196    }
197    if trimmed.contains('\\') || trimmed.contains('\0') {
198        bail!("namespace cannot contain backslashes or null bytes");
199    }
200    if trimmed.contains(' ') {
201        bail!("namespace cannot contain spaces (use hyphens or underscores)");
202    }
203    if !is_clean_string(trimmed) {
204        bail!("namespace contains invalid control characters");
205    }
206    // Task 1.4 — hierarchical paths. '/' is permitted as a delimiter, but
207    // leading/trailing/empty segments are rejected to force callers to
208    // normalize input first (ambiguity between "foo" and "foo/" is not
209    // something we want to paper over at match time).
210    if trimmed.starts_with('/') {
211        bail!("namespace cannot start with '/' (normalize input first)");
212    }
213    if trimmed.ends_with('/') {
214        bail!("namespace cannot end with '/' (normalize input first)");
215    }
216    if trimmed.split('/').any(str::is_empty) {
217        bail!("namespace cannot contain empty segments (e.g. '//')");
218    }
219    // Reject `..` and `.` segments — they look like path traversal to
220    // human readers and silently confuse hierarchy semantics. Visibility
221    // prefix matching with LIKE 'foo/%' would let memories at
222    // `foo/../malicious` appear under `foo`'s team-scope queries
223    // (red-team #240).
224    if trimmed.split('/').any(|s| s == ".." || s == ".") {
225        bail!("namespace segments '.' and '..' are not allowed");
226    }
227    let depth = crate::models::namespace_depth(trimmed);
228    if depth > MAX_NAMESPACE_DEPTH {
229        bail!("namespace depth {depth} exceeds max of {MAX_NAMESPACE_DEPTH}");
230    }
231    Ok(())
232}
233
234/// Normalize a namespace input to the canonical form accepted by
235/// [`validate_namespace`]. Not called by write paths (would lowercase
236/// existing flat namespaces and break their lookup keys); instead exposed
237/// as a helper that callers opt into, and used by Task 1.5+ when accepting
238/// user-typed hierarchical paths.
239///
240/// - Trim leading/trailing whitespace
241/// - Strip leading/trailing `/`
242/// - Collapse consecutive `/` into a single separator
243/// - Lowercase the result
244///
245/// This is a pure helper; the write path does **not** auto-apply it so that
246/// callers retain control over case sensitivity on existing flat namespaces.
247/// Use it when you need to accept loose user input and produce a matchable
248/// canonical key.
249#[allow(dead_code)]
250#[must_use]
251pub fn normalize_namespace(input: &str) -> String {
252    let trimmed = input.trim();
253    let collapsed: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
254    collapsed.join("/").to_lowercase()
255}
256
257pub fn validate_source(source: &str) -> Result<()> {
258    if source.trim().is_empty() {
259        bail!("source cannot be empty");
260    }
261    if source.len() > MAX_SOURCE_LEN {
262        bail!("source exceeds max length of {MAX_SOURCE_LEN} bytes");
263    }
264    if !VALID_SOURCES.contains(&source) {
265        bail!(
266            "invalid source '{}' — must be one of: {}",
267            source,
268            VALID_SOURCES.join(", ")
269        );
270    }
271    Ok(())
272}
273
274/// Reserved internal agent identifiers (issue #977).
275///
276/// Each of these names is used as a `CallerContext` principal by an
277/// internal admin/system path that constructs the context DIRECTLY via
278/// [`crate::store::CallerContext::for_admin`] — bypassing
279/// [`validate_agent_id`] by design. The downstream cross-tenant
280/// ownership gates carve out these literal strings as the "internal
281/// path is exempt" signal (e.g. `caller == "daemon"` in
282/// `src/handlers/parity.rs::require_caller_owns_memory`,
283/// `src/handlers/links.rs`, `src/handlers/kg.rs`,
284/// `src/handlers/hook_subscribers.rs`, `src/mcp/tools/namespace.rs`).
285///
286/// Without this guard, a wire caller setting `X-Agent-Id: daemon` (or
287/// any of the other reserved names) — or the same via the MCP-tool
288/// `agent_id` input field, or the HTTP body `agent_id` field — would
289/// reach `CallerContext.principal == "daemon"` and bypass every cross-
290/// tenant ownership gate. The list below MUST stay in sync with the
291/// production sites that construct `CallerContext::for_admin(...)` with
292/// literal-string principals; adding a new internal sentinel requires
293/// adding the matching reserved-name entry here.
294///
295/// Sites that legitimately use these as internal callers (each calls
296/// `CallerContext::for_admin(...)` directly and never traverses this
297/// validator):
298///
299/// - `"daemon"` → `src/handlers/admin.rs` (single `for_admin` site at
300///   `register_agent`; this list previously cited three line numbers
301///   that drifted — sites are now grep-able via
302///   `sentinels::DAEMON_PRINCIPAL`)
303/// - `"subscription-dispatch"` → `src/handlers/subscriptions.rs::dispatch_approval_requested`
304/// - `"ai:http-internal"` → `src/handlers/{http,power,hook_subscribers}.rs`
305/// - `"ai:migrate"` → `src/migrate.rs`
306/// - `"federation-catchup"` → `src/federation/receive.rs`
307/// - `"export-internal"` → `src/store/postgres.rs::export_*`
308/// - `"governance-internal"` → `src/store/postgres.rs::governance_*`
309/// - `"embedding-backfill"` → `src/daemon_runtime.rs` serve-boot
310///   embedding-backfill sweep (#1579 A4)
311/// - `"system"` → `src/handlers/hook_subscribers.rs` (stamped on
312///   legacy-rewrite rows; also matched as the unowned-marker sentinel
313///   in cross-tenant gates, so wire spoofing it would let the caller
314///   silently claim ownership of legacy-unowned rows).
315pub const RESERVED_AGENT_IDS: &[&str] = &[
316    crate::identity::sentinels::DAEMON_PRINCIPAL,
317    crate::identity::sentinels::SYSTEM_PRINCIPAL,
318    crate::identity::sentinels::FEDERATION_CATCHUP,
319    crate::identity::sentinels::SUBSCRIPTION_DISPATCH,
320    crate::identity::sentinels::AI_HTTP_INTERNAL,
321    crate::identity::sentinels::AI_MIGRATE,
322    crate::identity::sentinels::EXPORT_INTERNAL,
323    crate::identity::sentinels::GOVERNANCE_INTERNAL,
324    crate::identity::sentinels::EMBEDDING_BACKFILL,
325];
326
327/// Shape-only validation for an agent identifier — the pre-#977
328/// behaviour, separated so internal callers that legitimately need to
329/// load/generate keypairs with reserved-sentinel labels (e.g. the
330/// daemon's own self-signing keypair under
331/// [`crate::identity::keypair::DAEMON_KEYPAIR_LABEL`]) can opt into
332/// the looser check.
333///
334/// Allowed characters: alphanumeric plus `_`, `-`, `:`, `@`, `.`, `/`.
335/// Length: 1..=128 bytes. Rejects whitespace, null bytes, control
336/// chars, and shell metacharacters.
337///
338/// New callers SHOULD prefer [`validate_agent_id`] (the wire-side
339/// function that ALSO rejects [`RESERVED_AGENT_IDS`]). Use this
340/// shape-only entry point ONLY for internal paths that operate on
341/// hardcoded-literal sentinels (the daemon's keypair load + the
342/// internal admin `CallerContext::for_admin(...)` construction sites);
343/// every wire entry point (HTTP `X-Agent-Id` header, HTTP body
344/// `agent_id`, MCP-tool `agent_id` input, CLI `--as-agent`) MUST go
345/// through [`validate_agent_id`] instead.
346pub fn validate_agent_id_shape(agent_id: &str) -> Result<()> {
347    if agent_id.is_empty() {
348        bail!("agent_id cannot be empty");
349    }
350    if agent_id.len() > MAX_AGENT_ID_LEN {
351        bail!("agent_id exceeds max length of {MAX_AGENT_ID_LEN} bytes");
352    }
353    for c in agent_id.chars() {
354        if !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '@' | '.' | '/')) {
355            bail!("agent_id contains invalid character '{c}' (allowed: alphanumeric, _-:@./)");
356        }
357    }
358    // #1251 (security-medium, 2026-05-25) — block path-traversal
359    // sequences in identity strings that downstream callers consume as
360    // filename fragments (e.g. `<keydir>/<agent_id>.pub` in
361    // `crate::identity::keypair::load`). The pre-#1251 shape check
362    // permitted `.` and `/` separately, so an `observed_by` (or any
363    // wire-supplied agent_id) of `../../../etc/secret` resolved to a
364    // path OUTSIDE the keydir; a federated peer that could place a
365    // 32-byte valid Ed25519 pubkey anywhere on the receiver host could
366    // forge a `signed` attest-level. The reject mirrors the same
367    // discipline `validate_id` already enforces for memory IDs
368    // (`src/validate.rs::validate_id`, #1051).
369    //
370    // SPIFFE URIs (`spiffe://example.org/ns/prod`) are explicitly
371    // preserved: they contain a `//` (empty segment) after the scheme,
372    // but no `..`. The `..` ban is the load-bearing protection — empty
373    // segments are tolerated because `Path::join("<keydir>", "spiffe:")`
374    // and `Path::join(..., "")` both stay inside the keydir. The
375    // leading-`/` ban prevents `agent_id = "/etc/passwd"` from making
376    // `PathBuf::join` abandon the keydir entirely.
377    if agent_id.contains("..") {
378        bail!("agent_id may not contain '..' (path-traversal guard)");
379    }
380    if agent_id.starts_with('/') {
381        bail!("agent_id may not start with '/' (path-traversal guard)");
382    }
383    Ok(())
384}
385
386/// Validate an agent identifier (NHI-hardened) for wire-side use.
387///
388/// Calls [`validate_agent_id_shape`] for the shape check, then rejects
389/// the [`RESERVED_AGENT_IDS`] reserved-name set (issue #977) so wire
390/// callers cannot spoof an internal `CallerContext` principal. Internal
391/// callers constructing `CallerContext::for_admin` directly do not
392/// traverse this validator and remain unaffected; internal keypair
393/// load/generate uses [`validate_agent_id_shape`] (shape-only) so the
394/// daemon's `"daemon"`-labelled self-signing keypair still loads.
395///
396/// This is the function every WIRE entry point MUST call:
397/// - HTTP `X-Agent-Id` header / body `agent_id` field
398///   ([`crate::identity::resolve_http_agent_id`])
399/// - MCP-tool `agent_id` input (validated at each tool's entry point)
400/// - HTTP admin endpoints
401/// - CLI `--as-agent` / `identity generate`
402pub fn validate_agent_id(agent_id: &str) -> Result<()> {
403    validate_agent_id_shape(agent_id)?;
404    // #977 — block wire callers from spoofing the internal sentinels
405    // that downstream gates carve out as the "internal path is exempt"
406    // signal. Internal `CallerContext::for_admin(...)` constructions +
407    // the daemon's own keypair load (via `validate_agent_id_shape`)
408    // skip this reserved-name reject by design.
409    if RESERVED_AGENT_IDS.contains(&agent_id) {
410        bail!(
411            "agent_id '{agent_id}' is reserved for internal use and cannot be supplied by wire \
412             callers"
413        );
414    }
415    Ok(())
416}
417
418/// Validate a wire-supplied base64-encoded Ed25519 agent public key
419/// (#626 Layer-3, Task 1.3).
420///
421/// This is the WIRE entry-point guard for the `agent_pubkey` field on
422/// agent-registration and key-rotation requests. It bounds the input
423/// length (DoS guard on the base64 decode) and then confirms the value
424/// decodes to a well-formed 32-byte Ed25519 public key — i.e. a valid
425/// Edwards-curve point — by delegating to
426/// [`crate::identity::keypair::decode_public_base64`] (which accepts
427/// URL-safe-no-pad **or** standard-padded base64, the two flavors an
428/// operator might paste).
429///
430/// Validating here means a malformed key is rejected at the boundary —
431/// before it is bound into registration metadata where the attestation
432/// gate would later load it and fail opaquely on every signed write.
433///
434/// # Errors
435///
436/// - empty input
437/// - input longer than [`MAX_AGENT_PUBKEY_B64_LEN`]
438/// - input that does not decode to a 32-byte valid Ed25519 public key
439pub fn validate_agent_pubkey_b64(pubkey_b64: &str) -> Result<()> {
440    let trimmed = pubkey_b64.trim();
441    if trimmed.is_empty() {
442        bail!("agent_pubkey cannot be empty");
443    }
444    if pubkey_b64.len() > MAX_AGENT_PUBKEY_B64_LEN {
445        bail!("agent_pubkey exceeds max length of {MAX_AGENT_PUBKEY_B64_LEN} bytes");
446    }
447    // Delegate the decode + curve-point check to the single audited
448    // decoder so the wire validator and `identity import` agree on what
449    // "a valid pubkey" means. Map the error to a stable wire message.
450    crate::identity::keypair::decode_public_base64(trimmed)
451        .map_err(|e| anyhow::anyhow!("agent_pubkey is not a valid Ed25519 public key: {e:#}"))?;
452    Ok(())
453}
454
455/// Validate a visibility scope against the closed `VALID_SCOPES` set
456/// (Task 1.5). Enforced on write paths that accept an explicit `scope`
457/// parameter. Memories with no `scope` metadata are treated as `private`
458/// by the query layer without needing explicit validation here.
459pub fn validate_scope(scope: &str) -> Result<()> {
460    if scope.is_empty() {
461        bail!("scope cannot be empty");
462    }
463    if !VALID_SCOPES.contains(&scope) {
464        bail!(
465            "invalid scope '{}' — must be one of: {}",
466            scope,
467            VALID_SCOPES.join(", ")
468        );
469    }
470    Ok(())
471}
472
473/// Validate a [`GovernancePolicy`] (Task 1.8). Closed-set tag checks are
474/// already handled by serde on deserialization; this adds semantic bounds:
475/// consensus quorum must be ≥ 1, Agent references must pass
476/// `validate_agent_id`, and the policy as a whole must not use
477/// `GovernanceLevel::Approve` without a meaningful approver.
478pub fn validate_governance_policy(policy: &crate::models::GovernancePolicy) -> Result<()> {
479    use crate::models::{ApproverType, GovernanceLevel};
480    // #880 — `policy.core.approver` lives on the `core` sub-struct after
481    // the GovernancePolicy decomposition (PR-3). Same for `write`,
482    // `promote`, `delete`. Wire format is unchanged via
483    // `#[serde(flatten)]`; only Rust call sites move.
484    match &policy.core.approver {
485        ApproverType::Human => {}
486        ApproverType::Agent(id) => {
487            validate_agent_id(id)?;
488        }
489        ApproverType::Consensus(n) => {
490            if *n == 0 {
491                bail!("governance.approver.consensus quorum must be >= 1");
492            }
493        }
494    }
495    // `Approve` level is meaningless without a configured approver. The
496    // `Human` default is always valid, but a `Consensus(0)` or bad-id agent
497    // would have been caught above.
498    let uses_approve = matches!(policy.core.write, GovernanceLevel::Approve)
499        || matches!(policy.core.promote, GovernanceLevel::Approve)
500        || matches!(policy.core.delete, GovernanceLevel::Approve);
501    if uses_approve
502        && let ApproverType::Consensus(n) = &policy.core.approver
503        && *n == 0
504    {
505        bail!("governance uses 'approve' level but approver consensus is 0");
506    }
507    Ok(())
508}
509
510/// Maximum length for an `agent_type` string.
511const MAX_AGENT_TYPE_LEN: usize = 64;
512
513/// Validate an agent type. Accepts any value matching one of these forms
514/// (red-team #235 — the original closed whitelist blocked future agents):
515///
516/// - **Anything in [`VALID_AGENT_TYPES`]** — the curated short-list including
517///   `human`, `system`, and known AI model identifiers
518/// - **Any `ai:<name>` form** — `^ai:[A-Za-z0-9_.-]{1,60}$`. Lets operators
519///   register `ai:claude-opus-4.8`, `ai:gpt-5`, `ai:gemini-2.5`, etc. without
520///   waiting for a code release
521///
522/// Strict format guard: alphanumeric + `_-:.` only, max 64 bytes total.
523/// This keeps the value safe for SQL storage, JSON serialization, and
524/// shell display while removing the closed-list hard stop.
525pub fn validate_agent_type(agent_type: &str) -> Result<()> {
526    if agent_type.is_empty() {
527        bail!("agent_type cannot be empty");
528    }
529    if agent_type.len() > MAX_AGENT_TYPE_LEN {
530        bail!("agent_type exceeds max length of {MAX_AGENT_TYPE_LEN} bytes");
531    }
532    // Curated set always wins.
533    if VALID_AGENT_TYPES.contains(&agent_type) {
534        return Ok(());
535    }
536    // Open `ai:<name>` namespace for forward compatibility with future models.
537    if let Some(name) = agent_type.strip_prefix("ai:") {
538        if name.is_empty() {
539            bail!("agent_type 'ai:' must include a name (e.g. 'ai:claude-opus-4.7')");
540        }
541        if name
542            .chars()
543            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
544        {
545            return Ok(());
546        }
547        bail!(
548            "agent_type '{agent_type}' contains invalid characters in the ai: name \
549             part (allowed: alphanumeric, _-.)"
550        );
551    }
552    let valid = VALID_AGENT_TYPES.join(", ");
553    bail!("invalid agent_type '{agent_type}' — must be one of: {valid} (or any ai:<name> form)");
554}
555
556/// Validate a list of capability strings. Shares `validate_tags` rules
557/// (non-empty, <=128 bytes each, clean chars, <=50 entries).
558pub fn validate_capabilities(caps: &[String]) -> Result<()> {
559    validate_tags(caps)
560}
561
562pub fn validate_tags(tags: &[String]) -> Result<()> {
563    if tags.len() > MAX_TAGS_COUNT {
564        bail!("too many tags (max {MAX_TAGS_COUNT})");
565    }
566    for tag in tags {
567        let trimmed = tag.trim();
568        if trimmed.is_empty() {
569            bail!("tags cannot contain empty strings");
570        }
571        if trimmed.len() > MAX_TAG_LEN {
572            let preview: String = trimmed.chars().take(20).collect();
573            bail!("tag '{preview}...' exceeds max length of {MAX_TAG_LEN} bytes");
574        }
575        if !is_clean_string(trimmed) {
576            bail!("tag contains invalid characters");
577        }
578    }
579    Ok(())
580}
581
582pub fn validate_id(id: &str) -> Result<()> {
583    if id.trim().is_empty() {
584        bail!("id cannot be empty");
585    }
586    if id.len() > MAX_ID_LEN {
587        bail!("id exceeds max length of {MAX_ID_LEN} bytes");
588    }
589    if !is_clean_string(id) {
590        bail!("id contains invalid characters");
591    }
592    // #1051 (HIGH, 2026-05-21) — tighten ID validation to reject
593    // path-traversal sequences. Pre-#1051 the loose `is_clean_string`
594    // check allowed `/`, `\`, and `..` substrings. An attacker who
595    // could federate/import a memory with id = "../../../tmp/evil"
596    // could redirect downstream file writes (export-reflections,
597    // forensic dumps) outside the requested out-dir, overwriting
598    // operator-writable files. Now restricted to a SPIFFE-style
599    // alphanumeric + `_-.:@` charset with NO `..` substring and NO
600    // `/` or `\` at all (memory ids are not paths).
601    if id.contains("..") {
602        bail!("id may not contain '..' (path-traversal guard)");
603    }
604    if id.contains('/') || id.contains('\\') {
605        bail!("id may not contain '/' or '\\' (path-traversal guard)");
606    }
607    // Per-byte sanity: only [A-Za-z0-9_:.@-] survive. Any new
608    // character class needs an explicit add here.
609    if !id
610        .bytes()
611        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'.' | b'@' | b'-'))
612    {
613        bail!(
614            "id contains characters outside the allowed set [A-Za-z0-9_:.@-] \
615             (path-traversal guard)"
616        );
617    }
618    Ok(())
619}
620
621pub fn validate_expires_at(expires_at: Option<&str>) -> Result<()> {
622    if let Some(ts) = expires_at {
623        if !is_valid_rfc3339(ts) {
624            bail!("expires_at is not valid RFC3339: '{ts}'");
625        }
626        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
627            && dt < chrono::Utc::now()
628        {
629            bail!("expires_at is in the past");
630        }
631    }
632    Ok(())
633}
634
635pub fn validate_ttl_secs(ttl: Option<i64>) -> Result<()> {
636    if let Some(secs) = ttl {
637        if secs <= 0 {
638            bail!("ttl_secs must be positive (got {secs})");
639        }
640        if secs > 365 * crate::SECS_PER_DAY {
641            bail!("ttl_secs exceeds maximum of 1 year");
642        }
643    }
644    Ok(())
645}
646
647pub fn validate_metadata(metadata: &serde_json::Value) -> Result<()> {
648    if !metadata.is_object() {
649        bail!("metadata must be a JSON object");
650    }
651    let serialized = serde_json::to_string(metadata)
652        .map_err(|e| anyhow::anyhow!("metadata is not valid JSON: {e}"))?;
653    if serialized.len() > MAX_METADATA_SIZE {
654        bail!(
655            "metadata exceeds max size of {MAX_METADATA_SIZE} bytes (got {})",
656            serialized.len()
657        );
658    }
659    let depth = json_depth(metadata);
660    if depth > MAX_METADATA_DEPTH {
661        bail!("metadata nesting depth exceeds limit of {MAX_METADATA_DEPTH} (got {depth})");
662    }
663    Ok(())
664}
665
666fn json_depth(val: &serde_json::Value) -> usize {
667    match val {
668        serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
669        serde_json::Value::Array(arr) => 1 + arr.iter().map(json_depth).max().unwrap_or(0),
670        _ => 0,
671    }
672}
673
674pub fn validate_relation(relation: &str) -> Result<()> {
675    if relation.trim().is_empty() {
676        bail!("relation cannot be empty");
677    }
678    if relation.len() > MAX_RELATION_LEN {
679        bail!("relation exceeds max length of {MAX_RELATION_LEN} bytes");
680    }
681    // v0.7.0 Wave-3 Continuation 5 — accept the canonical set above
682    // PLUS any caller-supplied lowercase identifier (a-z + 0-9 +
683    // underscore) so cert harnesses + downstream tooling can use
684    // arbitrary relation labels like `next`, `mentions`, `parent_of`.
685    // Mirrors the AGE Cypher convention where edge labels are
686    // user-defined identifiers; the same posture lights up here for
687    // wire-shape uniformity. Rejects whitespace / control chars /
688    // shell metacharacters defensively.
689    if VALID_RELATIONS.contains(&relation) {
690        return Ok(());
691    }
692    let ok = !relation.is_empty()
693        && relation
694            .chars()
695            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
696    if !ok {
697        bail!(
698            "invalid relation '{}' — must match [a-z0-9_]+ or be one of: {}",
699            relation,
700            VALID_RELATIONS.join(", ")
701        );
702    }
703    Ok(())
704}
705
706pub fn validate_confidence(confidence: f64) -> Result<()> {
707    if confidence.is_nan() || confidence.is_infinite() {
708        bail!("confidence must be a finite number");
709    }
710    if !(0.0..=1.0).contains(&confidence) {
711        bail!("confidence must be between 0.0 and 1.0 (got {confidence})");
712    }
713    Ok(())
714}
715
716pub fn validate_priority(priority: i32) -> Result<()> {
717    if !(1..=10).contains(&priority) {
718        bail!("priority must be between 1 and 10 (got {priority})");
719    }
720    Ok(())
721}
722
723/// v0.7.0 Form 4 (issue #757) — maximum citations per memory. Keeps
724/// the JSON-encoded column bounded; an operator authoring legitimate
725/// fact-grain provenance rarely needs more than a handful of citations
726/// on a single memory, and the cap protects the substrate from
727/// pathological payloads.
728const MAX_CITATIONS_PER_MEMORY: usize = 64;
729/// v0.7.0 Form 4 — maximum byte length of a URI form. HTTP URLs are
730/// commonly bounded at 2 KiB; we set a slightly larger headroom for
731/// `doc:` / `file:` payloads while still bounding the column size.
732const MAX_SOURCE_URI_LEN: usize = 4_096;
733/// v0.7.0 Form 4 — accepted URI form schemes. `pub(crate)` so tests in
734/// sibling modules build valid `source_uri` fixtures from this SSOT
735/// rather than re-hardcoding a scheme literal that would rot if the
736/// accepted-scheme set changes.
737pub(crate) const VALID_SOURCE_URI_SCHEMES: &[&str] = &["uri:", "doc:", "file:"];
738
739/// v0.7.0 Form 4 (issue #757) — validate a [`Citation`] envelope.
740///
741/// Required invariants:
742/// * `uri` is non-empty after trim and starts with one of the typed
743///   schemes accepted by [`validate_source_uri`] (mirror semantics —
744///   citation URIs and source URIs share the same form).
745/// * `accessed_at` parses as RFC3339.
746/// * `hash` (when present) is exactly 64 lowercase hex characters
747///   (SHA-256 digest).
748/// * `span` (when present) satisfies [`validate_source_span`].
749///
750/// # Errors
751///
752/// Returns the first invariant failure encountered.
753pub fn validate_citation(c: &Citation) -> Result<()> {
754    validate_source_uri(&c.uri)?;
755    if !is_valid_rfc3339(&c.accessed_at) {
756        bail!(
757            "citation.accessed_at is not valid RFC3339: '{}'",
758            c.accessed_at
759        );
760    }
761    if let Some(ref h) = c.hash {
762        if h.len() != 64 || !h.chars().all(|ch| ch.is_ascii_hexdigit()) {
763            bail!("citation.hash must be 64 hex characters (SHA-256 digest)");
764        }
765    }
766    if let Some(ref span) = c.span {
767        validate_source_span(span)?;
768    }
769    Ok(())
770}
771
772/// v0.7.0 Form 4 — validate the full citations vector.
773///
774/// Caps the count at [`MAX_CITATIONS_PER_MEMORY`] and delegates each
775/// entry to [`validate_citation`].
776///
777/// # Errors
778///
779/// Returns the first failure encountered.
780pub fn validate_citations(citations: &[Citation]) -> Result<()> {
781    if citations.len() > MAX_CITATIONS_PER_MEMORY {
782        bail!(
783            "too many citations: {} exceeds cap of {MAX_CITATIONS_PER_MEMORY}",
784            citations.len()
785        );
786    }
787    for c in citations {
788        validate_citation(c)?;
789    }
790    Ok(())
791}
792
793/// v0.7.0 Form 4 (issue #757) — validate a URI-form source pointer.
794///
795/// Accepts three schemes:
796/// * `uri:<...>` — HTTP(S) URL or other absolute URI.
797/// * `doc:<...>` — substrate document id (caller-supplied opaque).
798/// * `file:<...>` — filesystem path.
799///
800/// In every case the payload after the scheme must be non-empty (the
801/// validator strips the scheme prefix and re-checks). Bare strings
802/// without a scheme are rejected so a caller does not accidentally
803/// stuff a role label into the URI column.
804///
805/// # Errors
806///
807/// Returns when the input is empty, exceeds [`MAX_SOURCE_URI_LEN`],
808/// uses an unrecognised scheme, or carries an empty payload.
809pub fn validate_source_uri(s: &str) -> Result<()> {
810    let trimmed = s.trim();
811    if trimmed.is_empty() {
812        bail!("source URI cannot be empty");
813    }
814    if trimmed.len() > MAX_SOURCE_URI_LEN {
815        bail!("source URI exceeds max length of {MAX_SOURCE_URI_LEN} bytes");
816    }
817    if !is_clean_string(trimmed) {
818        bail!("source URI contains invalid control characters");
819    }
820    let matched = VALID_SOURCE_URI_SCHEMES
821        .iter()
822        .find(|prefix| trimmed.starts_with(*prefix));
823    match matched {
824        Some(prefix) => {
825            let payload = &trimmed[prefix.len()..];
826            if payload.trim().is_empty() {
827                bail!("source URI scheme '{prefix}' has empty payload");
828            }
829            Ok(())
830        }
831        None => bail!(
832            "source URI must start with one of: {}",
833            VALID_SOURCE_URI_SCHEMES.join(", ")
834        ),
835    }
836}
837
838/// v0.7.0 Form 4 (issue #757) — validate a [`SourceSpan`] byte-range.
839///
840/// Requires `start < end` and bounds both values within
841/// [`usize::MAX`]. The half-open convention `[start, end)` matches
842/// Rust slice semantics — `body[span.start..span.end]` is the cited
843/// slice.
844///
845/// # Errors
846///
847/// Returns when `start >= end`.
848pub fn validate_source_span(span: &SourceSpan) -> Result<()> {
849    if span.start >= span.end {
850        bail!(
851            "source_span requires start < end (got start={}, end={})",
852            span.start,
853            span.end
854        );
855    }
856    Ok(())
857}
858
859/// v0.7.0 Form 4 / Cluster-A — body-aware [`SourceSpan`] validation.
860///
861/// Stricter superset of [`validate_source_span`]: in addition to the
862/// `start < end` invariant, this also requires:
863///
864/// 1. `span.end <= body.len()` — the half-open interval `[start, end)`
865///    must lie entirely within the body, so `body[span.start..span.end]`
866///    cannot panic on out-of-bounds.
867/// 2. Both `span.start` and `span.end` fall on UTF-8 char boundaries
868///    in `body`. Slicing on a non-boundary panics, which would break
869///    every downstream consumer (forensic export, CLI display, etc.).
870///
871/// Call this from validators that have the source body in hand (e.g.
872/// the atomisation writer, the citation validator on a known parent).
873/// Validators that only have the span (the bare-bones
874/// `validate_source_span` above) keep their lighter contract.
875///
876/// # Errors
877///
878/// Returns when `start >= end`, when `end > body.len()`, or when either
879/// endpoint lands mid-codepoint.
880pub fn validate_source_span_for_body(span: &SourceSpan, body: &str) -> Result<()> {
881    validate_source_span(span)?;
882    if span.end > body.len() {
883        bail!(
884            "source_span end={} exceeds body length {}",
885            span.end,
886            body.len()
887        );
888    }
889    if !body.is_char_boundary(span.start) {
890        bail!(
891            "source_span start={} is not a UTF-8 char boundary in body",
892            span.start
893        );
894    }
895    if !body.is_char_boundary(span.end) {
896        bail!(
897            "source_span end={} is not a UTF-8 char boundary in body",
898            span.end
899        );
900    }
901    Ok(())
902}
903
904/// Validate a full `CreateMemory` before insert.
905pub fn validate_create(mem: &CreateMemory) -> Result<()> {
906    validate_title(&mem.title)?;
907    validate_content(&mem.content)?;
908    validate_namespace(&mem.namespace)?;
909    validate_source(&mem.source)?;
910    validate_tags(&mem.tags)?;
911    validate_priority(mem.priority)?;
912    // #1591 — `confidence` is optional on the wire (omission resolves
913    // to the compiled default with `confidence_source = "default"`);
914    // only an explicit value needs range validation.
915    if let Some(confidence) = mem.confidence {
916        validate_confidence(confidence)?;
917    }
918    validate_expires_at(mem.expires_at.as_deref())?;
919    validate_ttl_secs(mem.ttl_secs)?;
920    validate_metadata(&mem.metadata)?;
921    // v0.7.0 #1467 — reject an explicit, non-parseable Form-6 `kind` at
922    // the DTO boundary so the HTTP / MCP write surfaces stop silently
923    // coercing it to `Observation` (CLI already rejects). The downstream
924    // `kind.and_then(from_str).unwrap_or_default()` then only ever
925    // defaults a genuinely-absent `kind`.
926    validate_kind(mem.kind.as_deref())?;
927    // v0.7.0 Form 4 — fact-provenance fields are optional but when
928    // supplied must satisfy the per-field invariants.
929    validate_citations(&mem.citations)?;
930    if let Some(ref uri) = mem.source_uri {
931        validate_source_uri(uri)?;
932    }
933    if let Some(ref span) = mem.source_span {
934        validate_source_span(span)?;
935    }
936    Ok(())
937}
938
939/// v0.7.0 #1467 — validate the optional Form-6 `kind` wire value.
940///
941/// `None` is `Ok` (the caller omitted it; the write path's
942/// auto-classify hook or the `Observation` default applies). A
943/// `Some(s)` value is `Ok` iff it matches a
944/// [`crate::models::MemoryKind`] variant exactly (lowercase,
945/// case-sensitive — matching the CLI's `--kind` gate). Any other
946/// `Some` value — wrong case (`"Claim"`), unknown (`"bogus"`),
947/// trailing whitespace (`"claim "`), or empty (`""`) — is rejected so
948/// all three write surfaces (CLI, HTTP, MCP) agree instead of two
949/// silently data-lossing the field where the third rejects it.
950pub fn validate_kind(kind: Option<&str>) -> Result<()> {
951    if let Some(s) = kind
952        && crate::models::MemoryKind::from_str(s).is_none()
953    {
954        let expected = crate::models::MemoryKind::all()
955            .iter()
956            .map(|k| k.as_str())
957            .collect::<Vec<_>>()
958            .join(", ");
959        bail!("invalid kind '{s}' (expected one of: {expected})");
960    }
961    Ok(())
962}
963
964/// Validate a full Memory (used for import).
965pub fn validate_memory(mem: &Memory) -> Result<()> {
966    validate_id(&mem.id)?;
967    validate_title(&mem.title)?;
968    validate_content(&mem.content)?;
969    validate_namespace(&mem.namespace)?;
970    validate_source(&mem.source)?;
971    validate_tags(&mem.tags)?;
972    validate_priority(mem.priority)?;
973    validate_confidence(mem.confidence)?;
974    if mem.access_count < 0 {
975        bail!("access_count cannot be negative");
976    }
977    if !is_valid_rfc3339(&mem.created_at) {
978        bail!("created_at is not valid RFC3339");
979    }
980    if !is_valid_rfc3339(&mem.updated_at) {
981        bail!("updated_at is not valid RFC3339");
982    }
983    if let Some(ref ts) = mem.last_accessed_at
984        && !is_valid_rfc3339(ts)
985    {
986        bail!("last_accessed_at is not valid RFC3339");
987    }
988    // Don't reject past expires_at on import — may be importing historical data
989    if let Some(ref ts) = mem.expires_at
990        && !is_valid_rfc3339(ts)
991    {
992        bail!("expires_at is not valid RFC3339");
993    }
994    validate_metadata(&mem.metadata)?;
995    // v0.7.0 Form 4 — fact-provenance fields on a full Memory import.
996    validate_citations(&mem.citations)?;
997    if let Some(ref uri) = mem.source_uri {
998        validate_source_uri(uri)?;
999    }
1000    if let Some(ref span) = mem.source_span {
1001        validate_source_span(span)?;
1002    }
1003    Ok(())
1004}
1005
1006/// Validate update fields (only validates present fields).
1007/// Note: `expires_at` allows past dates in updates for programmatic TTL management
1008/// and GC testing — only format is validated, not chronological ordering.
1009pub fn validate_update(update: &UpdateMemory) -> Result<()> {
1010    if let Some(ref t) = update.title {
1011        validate_title(t)?;
1012    }
1013    if let Some(ref c) = update.content {
1014        validate_content(c)?;
1015    }
1016    if let Some(ref ns) = update.namespace {
1017        validate_namespace(ns)?;
1018    }
1019    if let Some(ref tags) = update.tags {
1020        validate_tags(tags)?;
1021    }
1022    if let Some(p) = update.priority {
1023        validate_priority(p)?;
1024    }
1025    if let Some(c) = update.confidence {
1026        validate_confidence(c)?;
1027    }
1028    if let Some(ref ts) = update.expires_at {
1029        validate_expires_at_format(ts)?;
1030    }
1031    if let Some(ref meta) = update.metadata {
1032        validate_metadata(meta)?;
1033    }
1034    if let Some(ref uri) = update.source_uri {
1035        validate_source_uri(uri)?;
1036    }
1037    Ok(())
1038}
1039
1040/// Validate `expires_at` format only (no past-date check). Used by update path.
1041pub fn validate_expires_at_format(ts: &str) -> Result<()> {
1042    if !is_valid_rfc3339(ts) {
1043        bail!("expires_at is not valid RFC3339: '{ts}'");
1044    }
1045    Ok(())
1046}
1047
1048/// Validate link creation.
1049pub fn validate_link(source_id: &str, target_id: &str, relation: &str) -> Result<()> {
1050    validate_id(source_id)?;
1051    validate_id(target_id)?;
1052    validate_relation(relation)?;
1053    if source_id == target_id {
1054        bail!("cannot link a memory to itself");
1055    }
1056    Ok(())
1057}
1058
1059/// Validate consolidation request.
1060pub fn validate_consolidate(
1061    ids: &[String],
1062    title: &str,
1063    summary: &str,
1064    namespace: &str,
1065) -> Result<()> {
1066    if ids.len() < 2 {
1067        bail!("need at least 2 memory IDs to consolidate");
1068    }
1069    if ids.len() > 100 {
1070        bail!("cannot consolidate more than 100 memories at once");
1071    }
1072    let mut seen = std::collections::HashSet::new();
1073    for id in ids {
1074        validate_id(id)?;
1075        if !seen.insert(id) {
1076            bail!("duplicate memory ID: {id}");
1077        }
1078    }
1079    validate_title(title)?;
1080    validate_content(summary)?;
1081    validate_namespace(namespace)?;
1082    Ok(())
1083}
1084
1085// =====================================================================
1086// #966 — Shared `RequestValidator` facade (Wave-2 Tier-C1)
1087// =====================================================================
1088//
1089// Pre-#966 every wire surface duplicated the same "validate id +
1090// validate namespace + validate agent_id + ..." sequence in its own
1091// handler entry. The mechanical-line duplication grew alongside the
1092// substrate's wire surface (at v0.7.0:
1093// `EXPECTED_PRODUCTION_ROUTES_COUNT=89` HTTP routes +
1094// `Profile::full().expected_tool_count()=74` MCP tools +
1095// `EXPECTED_CLI_SUBCOMMANDS_DEFAULT=80` / `_SAL=82` CLI subcommands —
1096// see SSOT consts in `src/lib.rs`). Refactoring
1097// per-call validation chains to a single fluent surface lets all
1098// three caller layers (HTTP handlers, MCP tools, CLI subcommands)
1099// route field-level + cross-field checks through one canonical entry
1100// point. Adding a new cross-field invariant becomes one impl method
1101// instead of three audited duplicates.
1102//
1103// Design constraints:
1104// * Backward-compatible — every free function above (validate_id,
1105//   validate_namespace, ...) remains the lowest level primitive and
1106//   continues to compile / pass existing tests unchanged.
1107// * Zero-cost — `RequestValidator` is a unit struct with associated
1108//   functions only; no allocations, no per-call state.
1109// * Typed error path — [`ValidationError`] carries `field` + `reason`
1110//   so HTTP/MCP can surface structured responses without parsing the
1111//   `anyhow::Error` display string. `impl From<ValidationError> for
1112//   anyhow::Error` keeps the existing `?`-into-`anyhow` flow working
1113//   at call sites that haven't migrated to the typed variant.
1114
1115/// Typed validation failure surfaced by [`RequestValidator`] entry
1116/// points. Carries the offending `field` name and a `reason` string
1117/// matching the existing `bail!` shape so the wire-side error
1118/// messages remain byte-equal to the pre-#966 surface.
1119#[derive(Debug, Clone, PartialEq, Eq)]
1120pub struct ValidationError {
1121    /// Symbolic field name (e.g. `"namespace"`, `"agent_id"`, `"id"`,
1122    /// `"link.source_id"`). Wire callers use this for structured
1123    /// error envelopes; humans use it for stack/log triage.
1124    pub field: String,
1125    /// Human-readable reason. Mirrors the legacy `bail!` message so
1126    /// existing wire-level assertions (`error.contains("namespace")`,
1127    /// etc.) continue to pass without churn.
1128    pub reason: String,
1129}
1130
1131impl ValidationError {
1132    /// Compose a `ValidationError` with the canonical `<field>: <reason>`
1133    /// display form.
1134    #[must_use]
1135    pub fn new(field: impl Into<String>, reason: impl Into<String>) -> Self {
1136        Self {
1137            field: field.into(),
1138            reason: reason.into(),
1139        }
1140    }
1141
1142    /// Wrap a free-function validator failure under a typed field
1143    /// name. Used by the [`RequestValidator`] methods to attribute
1144    /// each free-function result to the originating struct field.
1145    fn from_anyhow(field: &str, err: anyhow::Error) -> Self {
1146        Self::new(field, err.to_string())
1147    }
1148}
1149
1150impl std::fmt::Display for ValidationError {
1151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1152        // Mirror the legacy `bail!` shape: surface the reason verbatim
1153        // so wire-side responses don't change byte-for-byte. The field
1154        // tag is exposed structurally via the public `field` member.
1155        write!(f, "{}", self.reason)
1156    }
1157}
1158
1159impl std::error::Error for ValidationError {}
1160
1161// Note: `From<ValidationError> for anyhow::Error` is provided
1162// automatically by anyhow's blanket impl over `E: Error + Send + Sync
1163// + 'static`. The `validation_error_into_anyhow_preserves_reason` test
1164// below pins that the blanket path keeps the reason string intact.
1165
1166/// Shared validation facade routed through by HTTP handlers, MCP
1167/// tools, and CLI subcommands (issue #966, Wave-2 Tier-C1).
1168///
1169/// Each method bundles the field-level + cross-field checks for a
1170/// single request shape. Behavior is identical to chaining the
1171/// per-field free functions in the order they appear inside the
1172/// method body — `RequestValidator` is the canonical surface for
1173/// adding NEW cross-field rules without forcing every caller to
1174/// re-audit its inline validator sequence.
1175///
1176/// # NSA CSI MCP Security mapping
1177///
1178/// Primary defense against **NSA concern (i) Tool parameter injection**
1179/// (real-world issue) and implementation of **NSA recommendation (c)
1180/// Validate parameters** per the NSA Cybersecurity Information document
1181/// on MCP security (U/OO/6030316-26 \| PP-26-1834, May 2026, Version
1182/// 1.0). Every wire-entry layer — HTTP routes
1183/// (`EXPECTED_PRODUCTION_ROUTES_COUNT=89` in `src/lib.rs`), MCP
1184/// tools (`Profile::full().expected_tool_count()=74` per
1185/// `src/profile.rs`), CLI subcommands
1186/// (`EXPECTED_CLI_SUBCOMMANDS_DEFAULT=80` / `_SAL=82` in `src/lib.rs`)
1187/// — routes DTO-bundling validation through
1188/// `RequestValidator` so adding a new cross-field invariant is one
1189/// struct-method edit rather than 3+ audited per-surface edits. The
1190/// typed `ValidationError { field, reason }` carries explicit field
1191/// attribution while preserving byte-equal wire-side error messages
1192/// for v0.6.x backwards compatibility. Mapping anchor:
1193/// `request_validator_input_validation` in
1194/// [`docs/compliance/_inventory/v0.7.0-capabilities.json`](../docs/compliance/_inventory/v0.7.0-capabilities.json);
1195/// narrative in
1196/// [`docs/compliance/nsa-csi-mcp.html`](../docs/compliance/nsa-csi-mcp.html)
1197/// §3.9 (concern i) and §4.3 (recommendation c).
1198///
1199/// # Example
1200///
1201/// ```ignore
1202/// use crate::validate::RequestValidator;
1203///
1204/// // Inside an HTTP handler:
1205/// RequestValidator::validate_create(&body)?;
1206///
1207/// // Inside an MCP tool:
1208/// RequestValidator::validate_link_triple(&source_id, &target_id, &relation)
1209///     .map_err(|e| e.to_string())?;
1210/// ```
1211pub struct RequestValidator;
1212
1213impl RequestValidator {
1214    /// Full `CreateMemory` request validation (HTTP `POST
1215    /// /api/v1/memories`, MCP `memory_store`, CLI `store`). Delegates
1216    /// to the free-function [`validate_create`] to preserve the
1217    /// existing field order and error wording.
1218    ///
1219    /// # Errors
1220    ///
1221    /// Returns the first per-field failure as a [`ValidationError`].
1222    pub fn validate_create(req: &CreateMemory) -> Result<(), ValidationError> {
1223        validate_create(req).map_err(|e| ValidationError::from_anyhow("create", e))
1224    }
1225
1226    /// Full `UpdateMemory` request validation (HTTP `PUT
1227    /// /api/v1/memories/{id}`, MCP `memory_update`, CLI `update`).
1228    /// Validates only the fields that are `Some(_)` per the
1229    /// `UpdateMemory` partial-update contract.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns the first per-field failure as a [`ValidationError`].
1234    pub fn validate_update(req: &UpdateMemory) -> Result<(), ValidationError> {
1235        validate_update(req).map_err(|e| ValidationError::from_anyhow("update", e))
1236    }
1237
1238    /// Full `Memory` validation (import / federation receive / admin
1239    /// restore paths). Validates every required field on the row
1240    /// itself — stricter than `validate_create` because the import
1241    /// row carries timestamps, IDs, etc. that the create surface
1242    /// stamps server-side.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns the first per-field failure as a [`ValidationError`].
1247    pub fn validate_memory(req: &Memory) -> Result<(), ValidationError> {
1248        validate_memory(req).map_err(|e| ValidationError::from_anyhow("memory", e))
1249    }
1250
1251    /// Link creation triple validation. Matches the legacy
1252    /// [`validate_link`] free function exactly.
1253    ///
1254    /// # Errors
1255    ///
1256    /// Returns the first per-field failure as a [`ValidationError`].
1257    pub fn validate_link_triple(
1258        source_id: &str,
1259        target_id: &str,
1260        relation: &str,
1261    ) -> Result<(), ValidationError> {
1262        validate_link(source_id, target_id, relation)
1263            .map_err(|e| ValidationError::from_anyhow("link", e))
1264    }
1265
1266    /// Memory-consolidation request validation. Mirrors
1267    /// [`validate_consolidate`] exactly.
1268    ///
1269    /// # Errors
1270    ///
1271    /// Returns the first per-field failure as a [`ValidationError`].
1272    pub fn validate_consolidate(
1273        ids: &[String],
1274        title: &str,
1275        summary: &str,
1276        namespace: &str,
1277    ) -> Result<(), ValidationError> {
1278        validate_consolidate(ids, title, summary, namespace)
1279            .map_err(|e| ValidationError::from_anyhow("consolidate", e))
1280    }
1281
1282    /// Single-field id validation, surfaced through the facade for
1283    /// consistency with the other entry points. Used by GET/DELETE
1284    /// handlers that don't have a richer DTO.
1285    ///
1286    /// # Errors
1287    ///
1288    /// Returns [`ValidationError`] tagged with `field = "id"`.
1289    pub fn validate_id(id: &str) -> Result<(), ValidationError> {
1290        validate_id(id).map_err(|e| ValidationError::from_anyhow("id", e))
1291    }
1292
1293    /// Single-field namespace validation.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns [`ValidationError`] tagged with `field = "namespace"`.
1298    pub fn validate_namespace(ns: &str) -> Result<(), ValidationError> {
1299        validate_namespace(ns).map_err(|e| ValidationError::from_anyhow("namespace", e))
1300    }
1301
1302    /// Wire-side agent_id validation (rejects shape violations AND
1303    /// the reserved internal sentinel set per issue #977).
1304    ///
1305    /// # Errors
1306    ///
1307    /// Returns [`ValidationError`] tagged with `field = "agent_id"`.
1308    pub fn validate_agent_id(agent_id: &str) -> Result<(), ValidationError> {
1309        validate_agent_id(agent_id).map_err(|e| ValidationError::from_anyhow("agent_id", e))
1310    }
1311
1312    /// Two-of-a-kind bundle: validate an `id` AND a `namespace` in
1313    /// one call. Saves a `?` per surface site where both come off
1314    /// the same request body (the dominant duplication pattern
1315    /// observed in the pre-#966 handler/MCP audit — `validate_id`
1316    /// and `validate_namespace` co-occur on >20 sites).
1317    ///
1318    /// # Errors
1319    ///
1320    /// Returns the first failure (id-first, then namespace).
1321    pub fn validate_id_and_namespace(id: &str, ns: &str) -> Result<(), ValidationError> {
1322        Self::validate_id(id)?;
1323        Self::validate_namespace(ns)?;
1324        Ok(())
1325    }
1326
1327    /// Three-of-a-kind bundle: validate `id` + `namespace` +
1328    /// `agent_id` together. Pre-#966 this was the canonical
1329    /// "ownership-checked write path" preamble; the facade lets new
1330    /// handlers express the intent as one call.
1331    ///
1332    /// # Errors
1333    ///
1334    /// Returns the first failure in declaration order.
1335    pub fn validate_owner_write(id: &str, ns: &str, agent_id: &str) -> Result<(), ValidationError> {
1336        Self::validate_id(id)?;
1337        Self::validate_namespace(ns)?;
1338        Self::validate_agent_id(agent_id)?;
1339        Ok(())
1340    }
1341
1342    /// Confidence (0.0..=1.0) + priority (1..=10) cross-field
1343    /// bundle. Mirrors the inline pair inside `validate_create`;
1344    /// surfaced here so callers that synthesize a custom DTO (e.g.
1345    /// the `bulk_create` postgres handler) get the same numeric
1346    /// gates without re-implementing them.
1347    ///
1348    /// # Errors
1349    ///
1350    /// Returns the first failure (confidence-first, then priority).
1351    pub fn validate_confidence_and_priority(
1352        confidence: f64,
1353        priority: i32,
1354    ) -> Result<(), ValidationError> {
1355        validate_confidence(confidence)
1356            .map_err(|e| ValidationError::from_anyhow("confidence", e))?;
1357        validate_priority(priority).map_err(|e| ValidationError::from_anyhow("priority", e))?;
1358        Ok(())
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    #[test]
1367    fn test_valid_title() {
1368        assert!(validate_title("BIND9 custom build").is_ok());
1369        assert!(validate_title("").is_err());
1370        assert!(validate_title("   ").is_err());
1371        assert!(validate_title(&"x".repeat(513)).is_err());
1372        assert!(validate_title("has\0null").is_err());
1373    }
1374
1375    #[test]
1376    fn test_valid_namespace_flat_backwards_compat() {
1377        // Task 1.4: flat namespaces must still validate exactly as before.
1378        assert!(validate_namespace("my-project").is_ok());
1379        assert!(validate_namespace("global").is_ok());
1380        assert!(validate_namespace("under_score").is_ok());
1381        assert!(validate_namespace("ai-memory-mcp-dev").is_ok());
1382        assert!(validate_namespace("_agents").is_ok());
1383    }
1384
1385    #[test]
1386    fn test_valid_namespace_rejections_preserved() {
1387        assert!(validate_namespace("").is_err());
1388        assert!(validate_namespace("   ").is_err());
1389        assert!(validate_namespace("has space").is_err());
1390        assert!(validate_namespace("has\\backslash").is_err());
1391        assert!(validate_namespace("has\0null").is_err());
1392        assert!(validate_namespace("has\x07bell").is_err());
1393    }
1394
1395    #[test]
1396    fn test_namespace_rejects_dot_segments_redteam_240() {
1397        // Red-team #240 — `..` and `.` segments must be rejected to
1398        // prevent hierarchy confusion / visibility prefix-match games.
1399        assert!(validate_namespace("acme/../other").is_err());
1400        assert!(validate_namespace("acme/./other").is_err());
1401        assert!(validate_namespace("..").is_err());
1402        assert!(validate_namespace(".").is_err());
1403        assert!(validate_namespace("acme/team/..").is_err());
1404        assert!(validate_namespace("../acme").is_err());
1405        // But two dots inside a name is fine — only standalone segments are blocked.
1406        assert!(validate_namespace("acme/team..special").is_ok());
1407        assert!(validate_namespace("acme/.dotfile").is_ok());
1408    }
1409
1410    #[test]
1411    fn test_namespace_length_bumped_to_512() {
1412        // Historical 128-char budget is a floor; 512 is the new max for paths.
1413        assert!(validate_namespace(&"x".repeat(128)).is_ok());
1414        assert!(validate_namespace(&"x".repeat(512)).is_ok());
1415        assert!(validate_namespace(&"x".repeat(513)).is_err());
1416    }
1417
1418    // Task 1.4 — hierarchical paths ---------------------------------------
1419
1420    #[test]
1421    fn test_hierarchical_paths_accepted() {
1422        assert!(validate_namespace("alphaone/engineering").is_ok());
1423        assert!(validate_namespace("alphaone/engineering/platform").is_ok());
1424        assert!(validate_namespace("a/b/c/d/e/f/g/h").is_ok(), "8 levels OK");
1425    }
1426
1427    #[test]
1428    fn test_hierarchical_depth_cap() {
1429        // 9 levels exceeds MAX_NAMESPACE_DEPTH (8)
1430        assert!(validate_namespace("a/b/c/d/e/f/g/h/i").is_err());
1431    }
1432
1433    #[test]
1434    fn test_hierarchical_rejects_leading_slash() {
1435        assert!(validate_namespace("/alphaone/engineering").is_err());
1436    }
1437
1438    #[test]
1439    fn test_hierarchical_rejects_trailing_slash() {
1440        assert!(validate_namespace("alphaone/engineering/").is_err());
1441    }
1442
1443    #[test]
1444    fn test_hierarchical_rejects_empty_segments() {
1445        assert!(validate_namespace("alphaone//engineering").is_err());
1446        assert!(validate_namespace("a///b").is_err());
1447    }
1448
1449    #[test]
1450    fn test_hierarchical_rejects_control_chars() {
1451        assert!(validate_namespace("a/b\x07c").is_err());
1452        assert!(validate_namespace("a/b\0c").is_err());
1453    }
1454
1455    #[test]
1456    fn test_normalize_namespace_strips_slashes() {
1457        assert_eq!(
1458            normalize_namespace("/alphaone/engineering/"),
1459            "alphaone/engineering"
1460        );
1461        assert_eq!(normalize_namespace("///a///b///"), "a/b");
1462    }
1463
1464    #[test]
1465    fn test_normalize_namespace_lowercases() {
1466        assert_eq!(
1467            normalize_namespace("AlphaOne/Engineering"),
1468            "alphaone/engineering"
1469        );
1470        assert_eq!(normalize_namespace("MYAPP"), "myapp");
1471    }
1472
1473    #[test]
1474    fn test_normalize_namespace_trims_whitespace() {
1475        assert_eq!(normalize_namespace("  alphaone/eng  "), "alphaone/eng");
1476    }
1477
1478    #[test]
1479    fn test_normalize_then_validate_roundtrip() {
1480        let raw = "/AlphaOne//Engineering/Platform/";
1481        let norm = normalize_namespace(raw);
1482        assert_eq!(norm, "alphaone/engineering/platform");
1483        assert!(validate_namespace(&norm).is_ok());
1484    }
1485
1486    #[test]
1487    fn test_valid_source() {
1488        assert!(validate_source("user").is_ok());
1489        assert!(validate_source("claude").is_ok());
1490        assert!(validate_source("hook").is_ok());
1491        assert!(validate_source("api").is_ok());
1492        assert!(validate_source("cli").is_ok());
1493        assert!(validate_source("import").is_ok());
1494        assert!(validate_source("").is_err());
1495        assert!(validate_source("random").is_err());
1496    }
1497
1498    #[test]
1499    fn test_valid_agent_id() {
1500        // Accepted NHI-hardened formats
1501        assert!(validate_agent_id("alice").is_ok());
1502        assert!(validate_agent_id("ai:claude-code@host-1:pid-123").is_ok());
1503        assert!(validate_agent_id("host:dev-1:pid-9-deadbeef").is_ok());
1504        assert!(validate_agent_id("anonymous:req-abcdef01").is_ok());
1505        assert!(validate_agent_id("anonymous:pid-42-0123abcd").is_ok());
1506        assert!(validate_agent_id("spiffe://example.org/ns/prod").is_ok());
1507        assert!(validate_agent_id("a").is_ok());
1508        assert!(validate_agent_id(&"a".repeat(128)).is_ok());
1509    }
1510
1511    #[test]
1512    fn test_invalid_agent_id() {
1513        // Empty / oversized
1514        assert!(validate_agent_id("").is_err());
1515        assert!(validate_agent_id(&"a".repeat(129)).is_err());
1516
1517        // Whitespace
1518        assert!(validate_agent_id("alice bob").is_err());
1519        assert!(validate_agent_id("alice\tbob").is_err());
1520        assert!(validate_agent_id(" alice").is_err());
1521        assert!(validate_agent_id("alice ").is_err());
1522
1523        // Null byte / control chars
1524        assert!(validate_agent_id("has\0null").is_err());
1525        assert!(validate_agent_id("has\x07bell").is_err());
1526        assert!(validate_agent_id("has\nnewline").is_err());
1527
1528        // Shell metacharacters
1529        assert!(validate_agent_id("alice;rm").is_err());
1530        assert!(validate_agent_id("alice|cat").is_err());
1531        assert!(validate_agent_id("alice&bg").is_err());
1532        assert!(validate_agent_id("alice$VAR").is_err());
1533        assert!(validate_agent_id("alice`cmd`").is_err());
1534        assert!(validate_agent_id("alice\\bs").is_err());
1535        assert!(validate_agent_id("alice?q").is_err());
1536        assert!(validate_agent_id("alice*glob").is_err());
1537    }
1538
1539    /// #977 — every reserved internal sentinel MUST be rejected by the
1540    /// wire-side validator. Each name corresponds to a downstream
1541    /// cross-tenant ownership gate that carves it out as the "internal
1542    /// path is exempt" signal; without this guard, a wire caller could
1543    /// spoof the sentinel via `X-Agent-Id` / MCP-tool `agent_id` / HTTP
1544    /// body `agent_id` and bypass every such gate.
1545    #[test]
1546    fn test_reserved_internal_agent_ids_rejected_977() {
1547        for &reserved in RESERVED_AGENT_IDS {
1548            let r = validate_agent_id(reserved);
1549            assert!(
1550                r.is_err(),
1551                "reserved agent_id '{reserved}' MUST be rejected on the wire (issue #977)",
1552            );
1553            // The error message must cite the reserved-name reason so
1554            // wire-side log triage can tell this apart from the generic
1555            // shape rejection (length / char class).
1556            let msg = r.unwrap_err().to_string();
1557            assert!(
1558                msg.contains("reserved for internal use"),
1559                "reserved-name reject must surface the dedicated reason; got: {msg}",
1560            );
1561        }
1562    }
1563
1564    /// #977 — the canonical NHI shapes that operators / agents legitimately
1565    /// stamp on the wire MUST continue to pass. Pins that the reserved-name
1566    /// set didn't accidentally swallow a legitimate prefix family.
1567    #[test]
1568    fn test_legitimate_agent_ids_still_pass_after_977() {
1569        // These are the shapes documented in CLAUDE.md "Agent Identity
1570        // (NHI)" and exercised across the integration suite.
1571        for legitimate in [
1572            "alice",
1573            "ai:claude-code@host-1:pid-123",
1574            "host:dev-1:pid-9-deadbeef",
1575            "anonymous:req-abcdef01",
1576            "anonymous:pid-42-0123abcd",
1577            "spiffe://example.org/ns/prod",
1578            // Sibling forms that share a SUBSTRING with reserved names
1579            // but are NOT themselves reserved.
1580            "daemon-1",
1581            "system-admin",
1582            "ai:daemon-impostor",
1583            "federation-catchup-v2",
1584            "subscription-dispatch-replica",
1585            "ai:http-internal-shadow",
1586            "export-internal-tester",
1587            "governance-internal-audit",
1588        ] {
1589            assert!(
1590                validate_agent_id(legitimate).is_ok(),
1591                "legitimate NHI shape '{legitimate}' MUST still pass after #977",
1592            );
1593        }
1594    }
1595
1596    /// #1251 — agent_id strings consumed as on-disk filename fragments
1597    /// (`<keydir>/<agent_id>.pub`) must reject path-traversal sequences
1598    /// at the shape validator. A federated peer that could supply
1599    /// `observed_by = "../../etc/some-pubkey"` would otherwise drive
1600    /// `keypair::load` to read 32 bytes from outside the keydir and
1601    /// accept that data as a valid signing key.
1602    #[test]
1603    fn test_agent_id_rejects_path_traversal_1251() {
1604        // Direct `..` substring.
1605        for traversal in [
1606            "..",
1607            "../foo",
1608            "foo/..",
1609            "foo/../bar",
1610            "ai:claude/../etc",
1611            "host:..",
1612            "....", // doubled `..` still contains `..`
1613        ] {
1614            let r = validate_agent_id_shape(traversal);
1615            assert!(
1616                r.is_err(),
1617                "path-traversal shape '{traversal}' must be rejected by validate_agent_id_shape",
1618            );
1619            let msg = r.unwrap_err().to_string();
1620            assert!(
1621                msg.contains("path-traversal") || msg.contains(".."),
1622                "reject message for '{traversal}' should cite path-traversal; got: {msg}",
1623            );
1624        }
1625
1626        // Leading `/` (absolute path escape).
1627        let r = validate_agent_id_shape("/etc/keys");
1628        assert!(r.is_err(), "leading '/' agent_id must be rejected");
1629        assert!(
1630            r.unwrap_err().to_string().contains("path-traversal"),
1631            "leading '/' must cite path-traversal in the error",
1632        );
1633    }
1634
1635    /// #1251 — confirm SPIFFE URIs (which contain a `//`) still pass.
1636    /// Empty path segments are tolerated because the `..` ban is the
1637    /// load-bearing guarantee — empty segments cannot escape the keydir
1638    /// on their own.
1639    #[test]
1640    fn test_agent_id_spiffe_still_ok_after_1251() {
1641        assert!(validate_agent_id_shape("spiffe://example.org/ns/prod").is_ok());
1642        assert!(validate_agent_id_shape("spiffe://a/b").is_ok());
1643    }
1644
1645    // -----------------------------------------------------------------
1646    // #626 Layer-3 (Task 1.3) — validate_agent_pubkey_b64
1647    // -----------------------------------------------------------------
1648
1649    /// A freshly generated keypair's exported base64 (the URL-safe-no-pad
1650    /// flavor) must pass the wire validator — this is the exact string an
1651    /// agent-registration request carries.
1652    #[test]
1653    fn test_agent_pubkey_b64_accepts_generated_key() {
1654        let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
1655        let b64 = kp.public_base64();
1656        assert!(
1657            validate_agent_pubkey_b64(&b64).is_ok(),
1658            "exported pubkey base64 must validate; got: {b64}",
1659        );
1660        // Surrounding whitespace (paste artifact) is tolerated.
1661        let padded = format!("  {b64}\n");
1662        assert!(validate_agent_pubkey_b64(&padded).is_ok());
1663    }
1664
1665    /// Standard-padded base64 (the other flavor an operator might paste)
1666    /// must also validate — `decode_public_base64` accepts both.
1667    #[test]
1668    fn test_agent_pubkey_b64_accepts_standard_padded() {
1669        use base64::Engine as _;
1670        let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
1671        let padded = base64::engine::general_purpose::STANDARD.encode(kp.public.to_bytes());
1672        assert!(
1673            validate_agent_pubkey_b64(&padded).is_ok(),
1674            "standard-padded pubkey base64 must validate; got: {padded}",
1675        );
1676    }
1677
1678    #[test]
1679    fn test_agent_pubkey_b64_rejects_empty() {
1680        assert!(validate_agent_pubkey_b64("").is_err());
1681        assert!(validate_agent_pubkey_b64("   \n").is_err());
1682    }
1683
1684    #[test]
1685    fn test_agent_pubkey_b64_rejects_overlong() {
1686        let overlong = "A".repeat(MAX_AGENT_PUBKEY_B64_LEN + 1);
1687        let err = validate_agent_pubkey_b64(&overlong).unwrap_err();
1688        assert!(
1689            err.to_string().contains("max length"),
1690            "overlong pubkey must cite the length bound; got: {err}",
1691        );
1692    }
1693
1694    #[test]
1695    fn test_agent_pubkey_b64_rejects_malformed() {
1696        // Not base64 at all.
1697        assert!(validate_agent_pubkey_b64("!!!not-base64!!!").is_err());
1698        // Valid base64 but wrong length (decodes to != 32 bytes).
1699        use base64::Engine as _;
1700        let short = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 16]);
1701        let err = validate_agent_pubkey_b64(&short).unwrap_err();
1702        assert!(
1703            err.to_string().contains("not a valid Ed25519 public key"),
1704            "wrong-length key must surface the dedicated reason; got: {err}",
1705        );
1706    }
1707
1708    #[test]
1709    fn test_validate_governance_policy_default_ok() {
1710        let p = crate::models::GovernancePolicy::default();
1711        assert!(validate_governance_policy(&p).is_ok());
1712    }
1713
1714    /// #1051 (HIGH, 2026-05-21) — path-traversal hardening for
1715    /// `validate_id`. Pre-#1051 these IDs were accepted; post-#1051
1716    /// every one must error. Regression pin against any future
1717    /// loosening that would re-introduce the export-reflection /
1718    /// forensic-dump file-overwrite attack vector.
1719    #[test]
1720    fn test_validate_id_rejects_path_traversal_1051() {
1721        for bad in [
1722            "../etc/passwd",
1723            "..",
1724            "../../",
1725            "../../../tmp/evil",
1726            "foo/../bar",
1727            "foo/bar",
1728            "/foo",
1729            "foo/",
1730            "foo//bar",
1731            "foo\\bar",
1732            "C:\\Users\\foo",
1733            "foo bar",      // whitespace (separately, but should fail)
1734            "rm -rf",       // shell meta
1735            "foo;rm",       // shell meta
1736            "..\\..\\evil", // windows-style traversal
1737        ] {
1738            assert!(
1739                validate_id(bad).is_err(),
1740                "validate_id('{bad}') must reject (path-traversal guard #1051)"
1741            );
1742        }
1743    }
1744
1745    #[test]
1746    fn test_validate_id_accepts_legitimate_ids_1051() {
1747        for ok in [
1748            "550e8400-e29b-41d4-a716-446655440000", // UUID
1749            "mem.abc123",
1750            "agent:claude-opus-4.7",
1751            "user@example.com",
1752            "namespace-foo_bar",
1753            "Mem_2026.05.21_xyz",
1754        ] {
1755            assert!(
1756                validate_id(ok).is_ok(),
1757                "validate_id('{ok}') must accept (legitimate id shape #1051)"
1758            );
1759        }
1760    }
1761
1762    #[test]
1763    fn test_validate_governance_consensus_zero_rejected() {
1764        use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
1765        let p = GovernancePolicy {
1766            core: CorePolicy {
1767                write: GovernanceLevel::Any,
1768                promote: GovernanceLevel::Any,
1769                delete: GovernanceLevel::Owner,
1770                approver: ApproverType::Consensus(0),
1771                inherit: true,
1772                max_reflection_depth: None,
1773            },
1774            ..Default::default()
1775        };
1776        assert!(validate_governance_policy(&p).is_err());
1777    }
1778
1779    #[test]
1780    fn test_validate_governance_agent_id_checked() {
1781        use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
1782        let bad = GovernancePolicy {
1783            core: CorePolicy {
1784                write: GovernanceLevel::Any,
1785                promote: GovernanceLevel::Any,
1786                delete: GovernanceLevel::Owner,
1787                approver: ApproverType::Agent("has space".to_string()),
1788                inherit: true,
1789                max_reflection_depth: None,
1790            },
1791            ..Default::default()
1792        };
1793        assert!(validate_governance_policy(&bad).is_err());
1794
1795        let good = GovernancePolicy {
1796            core: CorePolicy {
1797                write: GovernanceLevel::Any,
1798                promote: GovernanceLevel::Any,
1799                delete: GovernanceLevel::Owner,
1800                approver: ApproverType::Agent("alice".to_string()),
1801                inherit: true,
1802                max_reflection_depth: None,
1803            },
1804            ..Default::default()
1805        };
1806        assert!(validate_governance_policy(&good).is_ok());
1807    }
1808
1809    #[test]
1810    fn test_valid_scope() {
1811        for s in ["private", "team", "unit", "org", "collective"] {
1812            assert!(validate_scope(s).is_ok(), "{s} must be valid");
1813        }
1814    }
1815
1816    #[test]
1817    fn test_invalid_scope() {
1818        assert!(validate_scope("").is_err());
1819        assert!(validate_scope("public").is_err());
1820        assert!(validate_scope("PRIVATE").is_err());
1821        assert!(validate_scope("personal").is_err());
1822    }
1823
1824    #[test]
1825    fn test_valid_agent_type_curated_values() {
1826        assert!(validate_agent_type("ai:claude-opus-4.6").is_ok());
1827        assert!(validate_agent_type("ai:codex-5.4").is_ok());
1828        assert!(validate_agent_type("ai:grok-4.2").is_ok());
1829        assert!(validate_agent_type("human").is_ok());
1830        assert!(validate_agent_type("system").is_ok());
1831    }
1832
1833    #[test]
1834    fn test_valid_agent_type_open_ai_namespace_redteam_235() {
1835        // Red-team #235 — any `ai:<name>` form must be accepted so operators
1836        // can register future / custom AI agents without code changes.
1837        assert!(validate_agent_type("ai:claude-opus-4.8").is_ok());
1838        assert!(validate_agent_type("ai:gpt-5").is_ok());
1839        assert!(validate_agent_type("ai:gemini-2.5").is_ok());
1840        assert!(validate_agent_type("ai:custom_internal-model.v2").is_ok());
1841        assert!(validate_agent_type("ai:claude").is_ok());
1842    }
1843
1844    #[test]
1845    fn test_invalid_agent_type() {
1846        // Empty.
1847        assert!(validate_agent_type("").is_err());
1848        // Wrong prefix case (only lowercase `ai:` matches the open form).
1849        assert!(validate_agent_type("AI:CLAUDE").is_err());
1850        // Plain word without `ai:` and not in curated set.
1851        assert!(validate_agent_type("bogus").is_err());
1852        // `ai:` with no name part.
1853        assert!(validate_agent_type("ai:").is_err());
1854        // Invalid char inside the ai: name part.
1855        assert!(validate_agent_type("ai:foo bar").is_err());
1856        assert!(validate_agent_type("ai:foo;rm").is_err());
1857        // Too long.
1858        assert!(validate_agent_type(&format!("ai:{}", "x".repeat(80))).is_err());
1859    }
1860
1861    #[test]
1862    fn test_agents_namespace_accepted() {
1863        assert!(validate_namespace("_agents").is_ok());
1864    }
1865
1866    #[test]
1867    fn test_valid_tags() {
1868        assert!(validate_tags(&["dns".to_string(), "bind9".to_string()]).is_ok());
1869        assert!(validate_tags(&[]).is_ok());
1870        assert!(validate_tags(&[String::new()]).is_err());
1871        let too_many: Vec<String> = (0..51).map(|i| format!("tag{i}")).collect();
1872        assert!(validate_tags(&too_many).is_err());
1873    }
1874
1875    #[test]
1876    fn test_valid_relation() {
1877        // v0.7.0 Wave-3 Cont 5 (commit cb92998): `validate_relation`
1878        // accepts any `[a-z0-9_]+` identifier in addition to the
1879        // canonical `VALID_RELATIONS` set so S82/S65 chain markers and
1880        // arbitrary AGE-style edge labels round-trip through the wire.
1881        // The pre-cb92998 expectation that "invented_relation" must be
1882        // rejected is therefore obsolete — do not re-introduce it
1883        // unless production validation is tightened back to a
1884        // closed-set check. Coverage here splits into:
1885        //
1886        //   * canonical names — must always pass
1887        //   * caller-supplied lowercase identifiers — must pass
1888        //     post-cb92998
1889        //   * structurally malformed input — must still fail
1890        //     (uppercase, whitespace, slashes, empty)
1891        //
1892        // The malformed cases below are the surviving "negative"
1893        // coverage the dropped `invented_relation` assertion used to
1894        // anchor.
1895
1896        // Canonical relation names — accepted via the VALID_RELATIONS
1897        // fast path.
1898        assert!(validate_relation("related_to").is_ok());
1899        assert!(validate_relation("derived_from").is_ok());
1900        assert!(validate_relation("contradicts").is_ok());
1901        assert!(validate_relation("supersedes").is_ok());
1902        // v0.7.0 Task 3/8 (recursive learning) — `reflects_on` joins the
1903        // canonical set as the relation a reflection memory writes back
1904        // to each source it reflects on. See VALID_RELATIONS docstring.
1905        assert!(validate_relation("reflects_on").is_ok());
1906
1907        // Caller-supplied lowercase identifier — accepted by the
1908        // post-cb92998 permissive arm. Previously rejected.
1909        assert!(validate_relation("s82_chain_marker").is_ok());
1910        assert!(validate_relation("invented_relation").is_ok());
1911        assert!(validate_relation("mentions").is_ok());
1912
1913        // Structurally malformed input — still rejected.
1914        assert!(validate_relation("").is_err());
1915        assert!(validate_relation("BAD").is_err());
1916        assert!(validate_relation("bad relation").is_err());
1917        assert!(validate_relation("bad/relation").is_err());
1918        assert!(validate_relation("bad-relation").is_err());
1919    }
1920
1921    #[test]
1922    fn test_valid_confidence() {
1923        assert!(validate_confidence(0.0).is_ok());
1924        assert!(validate_confidence(0.5).is_ok());
1925        assert!(validate_confidence(1.0).is_ok());
1926        assert!(validate_confidence(-0.1).is_err());
1927        assert!(validate_confidence(1.1).is_err());
1928        assert!(validate_confidence(f64::NAN).is_err());
1929        assert!(validate_confidence(f64::INFINITY).is_err());
1930    }
1931
1932    #[test]
1933    fn test_valid_ttl() {
1934        assert!(validate_ttl_secs(None).is_ok());
1935        assert!(validate_ttl_secs(Some(crate::SECS_PER_HOUR)).is_ok());
1936        assert!(validate_ttl_secs(Some(0)).is_err());
1937        assert!(validate_ttl_secs(Some(-1)).is_err());
1938        assert!(validate_ttl_secs(Some(366 * crate::SECS_PER_DAY)).is_err());
1939    }
1940
1941    #[test]
1942    fn test_self_link_rejected() {
1943        assert!(validate_link("abc", "abc", "related_to").is_err());
1944        assert!(validate_link("abc", "def", "related_to").is_ok());
1945    }
1946
1947    #[test]
1948    fn test_valid_metadata() {
1949        assert!(validate_metadata(&serde_json::json!({})).is_ok());
1950        assert!(validate_metadata(&serde_json::json!({"key": "value"})).is_ok());
1951        assert!(validate_metadata(&serde_json::json!({"nested": {"a": 1}})).is_ok());
1952        // Non-object types rejected
1953        assert!(validate_metadata(&serde_json::json!("string")).is_err());
1954        assert!(validate_metadata(&serde_json::json!(42)).is_err());
1955        assert!(validate_metadata(&serde_json::json!([1, 2])).is_err());
1956        assert!(validate_metadata(&serde_json::json!(null)).is_err());
1957    }
1958
1959    #[test]
1960    fn test_clean_string_rejects_control_chars() {
1961        assert!(is_clean_string("normal text"));
1962        assert!(is_clean_string("with\nnewline"));
1963        assert!(is_clean_string("with\ttab"));
1964        assert!(!is_clean_string("has\0null"));
1965        assert!(!is_clean_string("has\x07bell"));
1966        assert!(!is_clean_string("has\x1b[31mANSI\x1b[0m"));
1967        assert!(!is_clean_string("has\x08backspace"));
1968    }
1969
1970    #[test]
1971    fn test_oversized_metadata_rejected() {
1972        let big_value = "x".repeat(MAX_METADATA_SIZE);
1973        let meta = serde_json::json!({"big": big_value});
1974        assert!(validate_metadata(&meta).is_err());
1975    }
1976
1977    #[test]
1978    fn test_deeply_nested_metadata_rejected() {
1979        // Build a 33-level deep object (exceeds MAX_METADATA_DEPTH of 32)
1980        let mut val = serde_json::json!("leaf");
1981        for _ in 0..33 {
1982            val = serde_json::json!({"nested": val});
1983        }
1984        assert!(validate_metadata(&val).is_err());
1985
1986        // 32 levels should be fine
1987        let mut val = serde_json::json!("leaf");
1988        for _ in 0..31 {
1989            val = serde_json::json!({"nested": val});
1990        }
1991        assert!(validate_metadata(&val).is_ok());
1992    }
1993
1994    // -----------------------------------------------------------------
1995    // W11/S11b: proptest properties — boundary + adversarial fuzz
1996    // -----------------------------------------------------------------
1997    use proptest::prelude::*;
1998
1999    proptest! {
2000        // Title rejection happens iff trimmed string is empty (whitespace-only or "").
2001        #[test]
2002        fn prop_validate_title_rejects_empty_strings_only_when_actually_empty(
2003            ws in r"[ \t\n]{0,16}",
2004            tail in r"[A-Za-z0-9 _\-.,!?]{0,80}",
2005        ) {
2006            // Whitespace-only must reject; otherwise title is valid (within char bounds).
2007            let title = format!("{ws}{tail}{ws}");
2008            let trimmed_empty = title.trim().is_empty();
2009            let result = validate_title(&title);
2010            if trimmed_empty {
2011                prop_assert!(result.is_err(), "whitespace-only title must reject: {:?}", title);
2012            } else if title.chars().count() <= 512 {
2013                prop_assert!(result.is_ok(), "non-empty trimmed title must accept: {:?}", title);
2014            }
2015        }
2016    }
2017
2018    proptest! {
2019        // Namespaces with control chars / spaces / backslashes / null bytes must reject.
2020        #[test]
2021        fn prop_validate_namespace_rejects_invalid_chars(
2022            base in r"[a-z][a-z0-9_-]{0,20}",
2023            // Pick one of the always-rejected chars and splice it in.
2024            bad in prop::sample::select(&[' ', '\\', '\0', '\x07', '\x1b', '\x08']),
2025        ) {
2026            let ns = format!("{base}{bad}suffix");
2027            prop_assert!(
2028                validate_namespace(&ns).is_err(),
2029                "namespace with bad char {:?} must reject: {:?}", bad, ns
2030            );
2031        }
2032    }
2033
2034    proptest! {
2035        // a/b/c style paths up to 8 levels with safe chars should validate.
2036        #[test]
2037        fn prop_validate_namespace_accepts_valid_hierarchy(
2038            segs in prop::collection::vec(r"[a-z][a-z0-9_-]{0,20}", 1..=8),
2039        ) {
2040            // Filter out `.` / `..` segments which the validator rejects.
2041            let safe: Vec<String> = segs
2042                .into_iter()
2043                .filter(|s| s != "." && s != "..")
2044                .collect();
2045            if safe.is_empty() {
2046                return Ok(());
2047            }
2048            let ns = safe.join("/");
2049            prop_assert!(
2050                validate_namespace(&ns).is_ok(),
2051                "valid hierarchy must accept: {:?}", ns
2052            );
2053        }
2054    }
2055
2056    proptest! {
2057        // Priority must accept 1..=10, reject anything outside that band.
2058        #[test]
2059        fn prop_validate_priority_rejects_outside_range(p in -1000i32..1000i32) {
2060            let result = validate_priority(p);
2061            if (1..=10).contains(&p) {
2062                prop_assert!(result.is_ok(), "priority {p} (in 1..=10) must accept");
2063            } else {
2064                prop_assert!(result.is_err(), "priority {p} (outside 1..=10) must reject");
2065            }
2066        }
2067    }
2068
2069    proptest! {
2070        // Confidence rejects NaN / infinity / out-of-band values, accepts [0.0, 1.0].
2071        // Documented behavior: rejects (does not clamp).
2072        #[test]
2073        fn prop_validate_confidence_clamps_or_rejects(c in -10.0f64..10.0f64) {
2074            let result = validate_confidence(c);
2075            if (0.0..=1.0).contains(&c) {
2076                prop_assert!(result.is_ok(), "confidence {c} in [0,1] must accept");
2077            } else {
2078                prop_assert!(result.is_err(), "confidence {c} outside [0,1] must reject");
2079            }
2080        }
2081
2082        #[test]
2083        fn prop_validate_confidence_nan_inf_always_rejected(_u in Just(())) {
2084            prop_assert!(validate_confidence(f64::NAN).is_err());
2085            prop_assert!(validate_confidence(f64::INFINITY).is_err());
2086            prop_assert!(validate_confidence(f64::NEG_INFINITY).is_err());
2087        }
2088    }
2089
2090    proptest! {
2091        // Self-link must reject for every relation type, regardless of id payload.
2092        #[test]
2093        fn prop_validate_link_rejects_self_link_for_every_relation(
2094            id in r"[a-z][a-zA-Z0-9_-]{0,32}",
2095            rel_idx in 0usize..5,
2096        ) {
2097            // v0.7.0 Task 3/8 (recursive learning) — `reflects_on` joins the
2098            // canonical relation set; the self-link rejection invariant
2099            // applies to it too.
2100            let relations = [
2101                "related_to",
2102                "supersedes",
2103                "contradicts",
2104                "derived_from",
2105                "reflects_on",
2106            ];
2107            let rel = relations[rel_idx];
2108            let result = validate_link(&id, &id, rel);
2109            prop_assert!(result.is_err(), "self-link must reject for relation {rel}, id {:?}", id);
2110        }
2111    }
2112
2113    // -----------------------------------------------------------------
2114    // Unicode-boundary unit tests (W11/S11b — visible-but-tricky chars)
2115    // -----------------------------------------------------------------
2116
2117    #[test]
2118    fn test_title_accepts_zero_width_joiner() {
2119        // ZWJ (U+200D) is not a control char; titles should accept it.
2120        assert!(validate_title("emoji\u{200D}joiner").is_ok());
2121    }
2122
2123    #[test]
2124    fn test_title_accepts_rtl_marks() {
2125        // Right-to-left mark (U+200F) and LRM (U+200E) are allowed (non-control).
2126        assert!(validate_title("hello\u{200F}world").is_ok());
2127        assert!(validate_title("hello\u{200E}world").is_ok());
2128    }
2129
2130    #[test]
2131    fn test_title_accepts_combining_chars() {
2132        // Combining acute accent on `e` (U+0065 U+0301) — distinct chars,
2133        // is_clean_string allows them; char count differs from byte count.
2134        assert!(validate_title("cafe\u{0301}").is_ok());
2135    }
2136
2137    #[test]
2138    fn test_title_rejects_unicode_bom_as_control() {
2139        // U+FEFF (BOM/zero-width no-break space) — Rust's `is_control` on BOM
2140        // returns false (it's a format char, not control). Document actual
2141        // behavior: titles containing BOM are accepted.
2142        assert!(validate_title("foo\u{FEFF}bar").is_ok());
2143    }
2144
2145    // -----------------------------------------------------------------
2146    // L0.7-2 Tier A — long-tail error path coverage
2147    // (lines 109, 207, 290, 357/358/361, 383, 438, validate_create /
2148    // _memory / _update / _consolidate body branches)
2149    // -----------------------------------------------------------------
2150
2151    #[test]
2152    fn content_with_control_chars_rejected() {
2153        // Line 109: content with control char (not \n or \t)
2154        let err = validate_content("has\x07bell").unwrap_err();
2155        let msg = format!("{err}");
2156        assert!(msg.contains("invalid characters"), "got: {msg}");
2157    }
2158
2159    #[test]
2160    fn content_with_null_byte_rejected() {
2161        let err = validate_content("has\0null").unwrap_err();
2162        assert!(format!("{err}").contains("invalid characters"));
2163    }
2164
2165    #[test]
2166    fn source_oversized_rejected() {
2167        // Line 207: source longer than MAX_SOURCE_LEN (64)
2168        let big = "x".repeat(65);
2169        let err = validate_source(&big).unwrap_err();
2170        let msg = format!("{err}");
2171        assert!(msg.contains("max length"), "got: {msg}");
2172    }
2173
2174    #[test]
2175    fn governance_approve_with_consensus_zero_rejected() {
2176        // Line 290: uses_approve && Consensus(0) — must error in the
2177        // post-approver-block sweep. We force consensus(0) into a policy
2178        // that also uses Approve at the write level.
2179        use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
2180        // Build with Human first so the approver block doesn't itself trip,
2181        // then swap to Consensus(0) directly. The Consensus(0) branch in
2182        // the approver block (line 276) ALREADY rejects this — the line
2183        // 290 branch is the second guard. The two branches are
2184        // semantically redundant for `Consensus(0)`; line 290 is reachable
2185        // only if approver block were ever loosened. Document the line
2186        // as defensive coverage; the existing
2187        // test_validate_governance_consensus_zero_rejected hits the
2188        // approver-block branch directly.
2189        let p = GovernancePolicy {
2190            core: CorePolicy {
2191                write: GovernanceLevel::Approve,
2192                promote: GovernanceLevel::Any,
2193                delete: GovernanceLevel::Owner,
2194                approver: ApproverType::Consensus(0),
2195                inherit: true,
2196                max_reflection_depth: None,
2197            },
2198            ..Default::default()
2199        };
2200        assert!(validate_governance_policy(&p).is_err());
2201    }
2202
2203    #[test]
2204    fn tag_oversized_rejected_with_preview() {
2205        // Lines 357-358: tag length > MAX_TAG_LEN (128), error message
2206        // embeds first 20 chars of trimmed tag as preview.
2207        let big = "x".repeat(129);
2208        let tags = vec![big];
2209        let err = validate_tags(&tags).unwrap_err();
2210        let msg = format!("{err}");
2211        assert!(msg.contains("max length"), "got: {msg}");
2212        assert!(msg.contains("xxxxxxxxxxxxxxxxxxxx"), "got: {msg}");
2213    }
2214
2215    #[test]
2216    fn tag_with_control_chars_rejected() {
2217        // Line 361: tag fails is_clean_string
2218        let tags = vec!["has\x07bell".to_string()];
2219        let err = validate_tags(&tags).unwrap_err();
2220        assert!(format!("{err}").contains("invalid characters"));
2221    }
2222
2223    #[test]
2224    fn expires_at_malformed_rfc3339_rejected() {
2225        // Line 383: expires_at not valid RFC3339
2226        let err = validate_expires_at(Some("not-a-date")).unwrap_err();
2227        let msg = format!("{err}");
2228        assert!(msg.contains("RFC3339"), "got: {msg}");
2229        assert!(msg.contains("not-a-date"), "got: {msg}");
2230    }
2231
2232    #[test]
2233    fn expires_at_none_is_ok() {
2234        // Branch: None arm of validate_expires_at
2235        assert!(validate_expires_at(None).is_ok());
2236    }
2237
2238    #[test]
2239    fn expires_at_future_is_ok() {
2240        // Far-future date — valid format, not in the past
2241        let future = "2099-01-01T00:00:00Z";
2242        assert!(validate_expires_at(Some(future)).is_ok());
2243    }
2244
2245    #[test]
2246    fn expires_at_past_rejected() {
2247        // Branch: parsed RFC3339, but earlier than Utc::now()
2248        let past = "2000-01-01T00:00:00Z";
2249        let err = validate_expires_at(Some(past)).unwrap_err();
2250        assert!(format!("{err}").contains("past"));
2251    }
2252
2253    #[test]
2254    fn relation_oversized_rejected() {
2255        // Line 438: relation longer than MAX_RELATION_LEN (64)
2256        let big = "x".repeat(65);
2257        let err = validate_relation(&big).unwrap_err();
2258        let msg = format!("{err}");
2259        assert!(msg.contains("max length"), "got: {msg}");
2260    }
2261
2262    // -----------------------------------------------------------------
2263    // L0.7-2 Tier A — validate_create / validate_memory full body
2264    // (lines 486-602: every per-field error branch)
2265    // -----------------------------------------------------------------
2266
2267    fn cm_valid() -> crate::models::CreateMemory {
2268        // Construct a valid CreateMemory via serde defaults — deserialise
2269        // from minimal JSON so we don't depend on private struct shape.
2270        serde_json::from_value(serde_json::json!({
2271            "title": "ok title",
2272            "content": "ok content body",
2273            "namespace": "validate-test",
2274            "tags": ["one", "two"],
2275            "priority": 5,
2276            "confidence": 0.9,
2277            "source": "api",
2278            "metadata": {"k": "v"},
2279        }))
2280        .expect("fixture deserialises")
2281    }
2282
2283    #[test]
2284    fn validate_create_happy_path() {
2285        let m = cm_valid();
2286        assert!(validate_create(&m).is_ok());
2287    }
2288
2289    #[test]
2290    fn validate_create_propagates_title_error() {
2291        let mut m = cm_valid();
2292        m.title = String::new();
2293        assert!(validate_create(&m).is_err());
2294    }
2295
2296    #[test]
2297    fn validate_create_propagates_content_error() {
2298        let mut m = cm_valid();
2299        m.content = String::new();
2300        assert!(validate_create(&m).is_err());
2301    }
2302
2303    #[test]
2304    fn validate_create_propagates_namespace_error() {
2305        let mut m = cm_valid();
2306        m.namespace = "has space".to_string();
2307        assert!(validate_create(&m).is_err());
2308    }
2309
2310    #[test]
2311    fn validate_create_propagates_source_error() {
2312        let mut m = cm_valid();
2313        m.source = "bogus".to_string();
2314        assert!(validate_create(&m).is_err());
2315    }
2316
2317    // --- v0.7.0 #1467 — Form-6 `kind` strict validation ------------------
2318
2319    #[test]
2320    fn validate_kind_none_is_ok() {
2321        assert!(validate_kind(None).is_ok());
2322    }
2323
2324    #[test]
2325    fn validate_kind_accepts_all_canonical_variants() {
2326        for k in crate::models::MemoryKind::all() {
2327            assert!(
2328                validate_kind(Some(k.as_str())).is_ok(),
2329                "canonical variant {:?} must validate",
2330                k.as_str()
2331            );
2332        }
2333    }
2334
2335    #[test]
2336    fn validate_kind_rejects_wrong_case_unknown_and_whitespace() {
2337        for bad in ["Claim", "bogus", "claim ", "OBSERVATION", ""] {
2338            let err = validate_kind(Some(bad)).unwrap_err().to_string();
2339            assert!(
2340                err.contains("invalid kind") && err.contains("expected one of"),
2341                "expected strict rejection for {bad:?}, got: {err}"
2342            );
2343        }
2344    }
2345
2346    #[test]
2347    fn validate_create_rejects_invalid_kind() {
2348        let mut m = cm_valid();
2349        m.kind = Some("bogus".to_string());
2350        assert!(validate_create(&m).is_err());
2351    }
2352
2353    #[test]
2354    fn validate_create_accepts_valid_kind() {
2355        let mut m = cm_valid();
2356        m.kind = Some("claim".to_string());
2357        assert!(validate_create(&m).is_ok());
2358    }
2359
2360    #[test]
2361    fn validate_create_propagates_tags_error() {
2362        let mut m = cm_valid();
2363        m.tags = vec![String::new()];
2364        assert!(validate_create(&m).is_err());
2365    }
2366
2367    #[test]
2368    fn validate_create_propagates_priority_error() {
2369        let mut m = cm_valid();
2370        m.priority = 11;
2371        assert!(validate_create(&m).is_err());
2372    }
2373
2374    #[test]
2375    fn validate_create_propagates_confidence_error() {
2376        let mut m = cm_valid();
2377        m.confidence = Some(1.5);
2378        assert!(validate_create(&m).is_err());
2379        // #1591 — omission is valid (resolves to the compiled default
2380        // with `confidence_source = "default"`).
2381        m.confidence = None;
2382        assert!(validate_create(&m).is_ok());
2383    }
2384
2385    #[test]
2386    fn validate_create_propagates_expires_at_error() {
2387        let mut m = cm_valid();
2388        m.expires_at = Some("not-a-date".to_string());
2389        assert!(validate_create(&m).is_err());
2390    }
2391
2392    #[test]
2393    fn validate_create_propagates_ttl_error() {
2394        let mut m = cm_valid();
2395        m.ttl_secs = Some(-1);
2396        assert!(validate_create(&m).is_err());
2397    }
2398
2399    #[test]
2400    fn validate_create_propagates_metadata_error() {
2401        let mut m = cm_valid();
2402        m.metadata = serde_json::json!("not-an-object");
2403        assert!(validate_create(&m).is_err());
2404    }
2405
2406    // -----------------------------------------------------------------
2407    // validate_memory body branches (lines 498-528)
2408    // -----------------------------------------------------------------
2409
2410    fn mem_valid() -> crate::models::Memory {
2411        crate::models::Memory {
2412            id: "mem-1".to_string(),
2413            title: "ok title".to_string(),
2414            content: "ok content".to_string(),
2415            namespace: "validate-test".to_string(),
2416            source: "api".to_string(),
2417            tags: vec!["one".to_string()],
2418            priority: 5,
2419            confidence: 1.0,
2420            access_count: 0,
2421            created_at: "2026-01-01T00:00:00Z".to_string(),
2422            updated_at: "2026-01-01T00:00:00Z".to_string(),
2423            ..Default::default()
2424        }
2425    }
2426
2427    #[test]
2428    fn validate_memory_happy_path() {
2429        let m = mem_valid();
2430        assert!(validate_memory(&m).is_ok());
2431    }
2432
2433    #[test]
2434    fn validate_memory_rejects_empty_id() {
2435        let mut m = mem_valid();
2436        m.id = String::new();
2437        assert!(validate_memory(&m).is_err());
2438    }
2439
2440    #[test]
2441    fn validate_memory_rejects_negative_access_count() {
2442        let mut m = mem_valid();
2443        m.access_count = -1;
2444        let err = validate_memory(&m).unwrap_err();
2445        assert!(format!("{err}").contains("access_count"));
2446    }
2447
2448    #[test]
2449    fn validate_memory_rejects_malformed_created_at() {
2450        let mut m = mem_valid();
2451        m.created_at = "not-a-date".to_string();
2452        let err = validate_memory(&m).unwrap_err();
2453        assert!(format!("{err}").contains("created_at"));
2454    }
2455
2456    #[test]
2457    fn validate_memory_rejects_malformed_updated_at() {
2458        let mut m = mem_valid();
2459        m.updated_at = "not-a-date".to_string();
2460        let err = validate_memory(&m).unwrap_err();
2461        assert!(format!("{err}").contains("updated_at"));
2462    }
2463
2464    #[test]
2465    fn validate_memory_rejects_malformed_last_accessed_at() {
2466        let mut m = mem_valid();
2467        m.last_accessed_at = Some("not-a-date".to_string());
2468        let err = validate_memory(&m).unwrap_err();
2469        assert!(format!("{err}").contains("last_accessed_at"));
2470    }
2471
2472    #[test]
2473    fn validate_memory_accepts_valid_last_accessed_at() {
2474        let mut m = mem_valid();
2475        m.last_accessed_at = Some("2026-01-01T00:00:00Z".to_string());
2476        assert!(validate_memory(&m).is_ok());
2477    }
2478
2479    #[test]
2480    fn validate_memory_rejects_malformed_expires_at() {
2481        let mut m = mem_valid();
2482        m.expires_at = Some("not-a-date".to_string());
2483        let err = validate_memory(&m).unwrap_err();
2484        assert!(format!("{err}").contains("expires_at"));
2485    }
2486
2487    #[test]
2488    fn validate_memory_accepts_past_expires_at_for_import() {
2489        // Importers must be able to bring in historically expired rows.
2490        let mut m = mem_valid();
2491        m.expires_at = Some("2000-01-01T00:00:00Z".to_string());
2492        assert!(validate_memory(&m).is_ok());
2493    }
2494
2495    // -----------------------------------------------------------------
2496    // validate_update body branches (lines 534-559)
2497    // -----------------------------------------------------------------
2498
2499    fn upd() -> crate::models::UpdateMemory {
2500        serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises")
2501    }
2502
2503    #[test]
2504    fn validate_update_empty_is_ok() {
2505        assert!(validate_update(&upd()).is_ok());
2506    }
2507
2508    #[test]
2509    fn validate_update_propagates_title_error() {
2510        let mut u = upd();
2511        u.title = Some(String::new());
2512        assert!(validate_update(&u).is_err());
2513    }
2514
2515    #[test]
2516    fn validate_update_propagates_content_error() {
2517        let mut u = upd();
2518        u.content = Some(String::new());
2519        assert!(validate_update(&u).is_err());
2520    }
2521
2522    #[test]
2523    fn validate_update_propagates_namespace_error() {
2524        let mut u = upd();
2525        u.namespace = Some("has space".to_string());
2526        assert!(validate_update(&u).is_err());
2527    }
2528
2529    #[test]
2530    fn validate_update_propagates_tags_error() {
2531        let mut u = upd();
2532        u.tags = Some(vec![String::new()]);
2533        assert!(validate_update(&u).is_err());
2534    }
2535
2536    #[test]
2537    fn validate_update_propagates_priority_error() {
2538        let mut u = upd();
2539        u.priority = Some(11);
2540        assert!(validate_update(&u).is_err());
2541    }
2542
2543    #[test]
2544    fn validate_update_propagates_confidence_error() {
2545        let mut u = upd();
2546        u.confidence = Some(2.0);
2547        assert!(validate_update(&u).is_err());
2548    }
2549
2550    #[test]
2551    fn validate_update_propagates_expires_at_format_error() {
2552        let mut u = upd();
2553        u.expires_at = Some("not-a-date".to_string());
2554        assert!(validate_update(&u).is_err());
2555    }
2556
2557    #[test]
2558    fn validate_update_allows_past_expires_at() {
2559        // Per the docstring: update path validates format only, not chronology.
2560        let mut u = upd();
2561        u.expires_at = Some("2000-01-01T00:00:00Z".to_string());
2562        assert!(validate_update(&u).is_ok());
2563    }
2564
2565    #[test]
2566    fn validate_update_propagates_metadata_error() {
2567        let mut u = upd();
2568        u.metadata = Some(serde_json::json!("not-an-object"));
2569        assert!(validate_update(&u).is_err());
2570    }
2571
2572    #[test]
2573    fn validate_expires_at_format_accepts_past_date() {
2574        // Direct coverage of the format-only helper.
2575        assert!(validate_expires_at_format("2000-01-01T00:00:00Z").is_ok());
2576        assert!(validate_expires_at_format("not-a-date").is_err());
2577    }
2578
2579    // -----------------------------------------------------------------
2580    // validate_consolidate body branches (lines 588-604)
2581    // -----------------------------------------------------------------
2582
2583    #[test]
2584    fn consolidate_too_few_ids_rejected() {
2585        let err = validate_consolidate(&["only-one".to_string()], "title", "summary content", "ns")
2586            .unwrap_err();
2587        assert!(format!("{err}").contains("at least 2"));
2588    }
2589
2590    #[test]
2591    fn consolidate_too_many_ids_rejected() {
2592        let ids: Vec<String> = (0..101).map(|i| format!("id-{i}")).collect();
2593        let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2594        assert!(format!("{err}").contains("100"));
2595    }
2596
2597    #[test]
2598    fn consolidate_duplicate_ids_rejected() {
2599        let ids = vec!["a".to_string(), "a".to_string()];
2600        let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2601        assert!(format!("{err}").contains("duplicate"));
2602    }
2603
2604    #[test]
2605    fn consolidate_invalid_id_rejected() {
2606        let ids = vec!["valid".to_string(), String::new()];
2607        // Empty id fails validate_id
2608        let err = validate_consolidate(&ids, "title", "summary content", "ns").unwrap_err();
2609        assert!(format!("{err}").contains("id"));
2610    }
2611
2612    #[test]
2613    fn consolidate_invalid_title_rejected() {
2614        let ids = vec!["a".to_string(), "b".to_string()];
2615        assert!(validate_consolidate(&ids, "", "summary content", "ns").is_err());
2616    }
2617
2618    #[test]
2619    fn consolidate_invalid_summary_rejected() {
2620        let ids = vec!["a".to_string(), "b".to_string()];
2621        assert!(validate_consolidate(&ids, "title", "", "ns").is_err());
2622    }
2623
2624    #[test]
2625    fn consolidate_invalid_namespace_rejected() {
2626        let ids = vec!["a".to_string(), "b".to_string()];
2627        assert!(validate_consolidate(&ids, "title", "summary content", "has space").is_err());
2628    }
2629
2630    #[test]
2631    fn consolidate_happy_path() {
2632        let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2633        assert!(validate_consolidate(&ids, "title", "summary content", "ns").is_ok());
2634    }
2635
2636    // -----------------------------------------------------------------
2637    // validate_capabilities — wrapper around validate_tags
2638    // -----------------------------------------------------------------
2639
2640    #[test]
2641    fn capabilities_delegates_to_tags() {
2642        assert!(validate_capabilities(&["read".to_string(), "write".to_string()]).is_ok());
2643        assert!(validate_capabilities(&[String::new()]).is_err());
2644    }
2645
2646    #[test]
2647    fn id_oversized_rejected() {
2648        let big = "a".repeat(129);
2649        let err = validate_id(&big).unwrap_err();
2650        assert!(format!("{err}").contains("max length"));
2651    }
2652
2653    #[test]
2654    fn id_with_control_chars_rejected() {
2655        let err = validate_id("has\0null").unwrap_err();
2656        assert!(format!("{err}").contains("invalid characters"));
2657    }
2658
2659    // -----------------------------------------------------------------
2660    // v0.7-polish coverage recovery (issue #767) — Form 4 validator
2661    // reject paths for validate_citation / validate_source_uri /
2662    // validate_source_span / validate_source_span_for_body /
2663    // validate_citations.
2664    // -----------------------------------------------------------------
2665
2666    fn good_citation() -> crate::models::Citation {
2667        crate::models::Citation {
2668            uri: "doc:abc".to_string(),
2669            accessed_at: "2026-01-01T00:00:00Z".to_string(),
2670            hash: None,
2671            span: None,
2672        }
2673    }
2674
2675    #[test]
2676    fn validate_source_uri_rejects_empty_string() {
2677        let err = validate_source_uri("").unwrap_err();
2678        assert!(format!("{err}").contains("cannot be empty"));
2679    }
2680
2681    #[test]
2682    fn validate_source_uri_rejects_whitespace_only() {
2683        let err = validate_source_uri("   \t  ").unwrap_err();
2684        assert!(format!("{err}").contains("cannot be empty"));
2685    }
2686
2687    #[test]
2688    fn validate_source_uri_rejects_bare_string_without_scheme() {
2689        let err = validate_source_uri("example.com/path").unwrap_err();
2690        let msg = format!("{err}");
2691        assert!(msg.contains("must start with"), "got: {msg}");
2692        assert!(msg.contains("uri:") || msg.contains("doc:") || msg.contains("file:"));
2693    }
2694
2695    #[test]
2696    fn validate_source_uri_rejects_control_chars() {
2697        let err = validate_source_uri("uri:has\x07ctrl").unwrap_err();
2698        assert!(format!("{err}").contains("invalid control characters"));
2699    }
2700
2701    #[test]
2702    fn validate_source_uri_rejects_oversize_input() {
2703        let big = format!("uri:{}", "a".repeat(8_000));
2704        let err = validate_source_uri(&big).unwrap_err();
2705        assert!(format!("{err}").contains("max length"));
2706    }
2707
2708    #[test]
2709    fn validate_source_uri_rejects_scheme_with_empty_payload() {
2710        let err = validate_source_uri("doc:").unwrap_err();
2711        assert!(format!("{err}").contains("empty payload"));
2712        let err = validate_source_uri("file:   ").unwrap_err();
2713        assert!(format!("{err}").contains("empty payload"));
2714    }
2715
2716    #[test]
2717    fn validate_source_uri_accepts_three_known_schemes() {
2718        assert!(validate_source_uri("uri:https://example.com").is_ok());
2719        assert!(validate_source_uri("doc:abc-123").is_ok());
2720        assert!(validate_source_uri("file:/etc/hosts").is_ok());
2721    }
2722
2723    #[test]
2724    fn validate_source_span_rejects_end_lt_start() {
2725        let span = crate::models::SourceSpan { start: 10, end: 5 };
2726        let err = validate_source_span(&span).unwrap_err();
2727        let msg = format!("{err}");
2728        assert!(msg.contains("start") && msg.contains("end"), "got: {msg}");
2729    }
2730
2731    #[test]
2732    fn validate_source_span_rejects_end_eq_start() {
2733        // Half-open interval requires strict start < end.
2734        let span = crate::models::SourceSpan { start: 4, end: 4 };
2735        assert!(validate_source_span(&span).is_err());
2736    }
2737
2738    #[test]
2739    fn validate_source_span_accepts_valid_range() {
2740        let span = crate::models::SourceSpan { start: 0, end: 10 };
2741        assert!(validate_source_span(&span).is_ok());
2742    }
2743
2744    #[test]
2745    fn validate_source_span_for_body_rejects_end_gt_body_len() {
2746        let body = "hello";
2747        let span = crate::models::SourceSpan { start: 0, end: 10 };
2748        let err = validate_source_span_for_body(&span, body).unwrap_err();
2749        assert!(format!("{err}").contains("exceeds body length"));
2750    }
2751
2752    #[test]
2753    fn validate_source_span_for_body_rejects_non_char_boundary_start() {
2754        // "é" is two bytes in UTF-8 (0xC3 0xA9); offset 1 falls
2755        // mid-codepoint.
2756        let body = "é-pattern";
2757        let span = crate::models::SourceSpan { start: 1, end: 3 };
2758        let err = validate_source_span_for_body(&span, body).unwrap_err();
2759        assert!(format!("{err}").contains("char boundary"));
2760    }
2761
2762    #[test]
2763    fn validate_source_span_for_body_rejects_non_char_boundary_end() {
2764        let body = "aéb";
2765        let span = crate::models::SourceSpan { start: 0, end: 2 };
2766        let err = validate_source_span_for_body(&span, body).unwrap_err();
2767        assert!(format!("{err}").contains("char boundary"));
2768    }
2769
2770    #[test]
2771    fn validate_source_span_for_body_accepts_full_body_slice() {
2772        let body = "hello world";
2773        let span = crate::models::SourceSpan {
2774            start: 0,
2775            end: body.len(),
2776        };
2777        assert!(validate_source_span_for_body(&span, body).is_ok());
2778    }
2779
2780    #[test]
2781    fn validate_citation_rejects_bad_uri() {
2782        let mut c = good_citation();
2783        c.uri = "bare-string-no-scheme".to_string();
2784        let err = validate_citation(&c).unwrap_err();
2785        assert!(format!("{err}").contains("must start with"));
2786    }
2787
2788    #[test]
2789    fn validate_citation_rejects_bad_accessed_at() {
2790        let mut c = good_citation();
2791        c.accessed_at = "not-a-date".to_string();
2792        let err = validate_citation(&c).unwrap_err();
2793        assert!(format!("{err}").contains("RFC3339"));
2794    }
2795
2796    #[test]
2797    fn validate_citation_rejects_short_hash() {
2798        let mut c = good_citation();
2799        c.hash = Some("deadbeef".to_string()); // 8 chars, not 64
2800        let err = validate_citation(&c).unwrap_err();
2801        assert!(format!("{err}").contains("64 hex"));
2802    }
2803
2804    #[test]
2805    fn validate_citation_rejects_non_hex_hash() {
2806        let mut c = good_citation();
2807        // Right length, wrong alphabet (contains 'z').
2808        c.hash = Some(format!("{}z", "a".repeat(63)));
2809        let err = validate_citation(&c).unwrap_err();
2810        assert!(format!("{err}").contains("64 hex"));
2811    }
2812
2813    #[test]
2814    fn validate_citation_accepts_valid_hash() {
2815        let mut c = good_citation();
2816        c.hash = Some("a".repeat(64));
2817        assert!(validate_citation(&c).is_ok());
2818    }
2819
2820    #[test]
2821    fn validate_citation_propagates_span_rejection() {
2822        let mut c = good_citation();
2823        c.span = Some(crate::models::SourceSpan { start: 5, end: 1 });
2824        let err = validate_citation(&c).unwrap_err();
2825        assert!(format!("{err}").contains("source_span"));
2826    }
2827
2828    #[test]
2829    fn validate_citation_accepts_minimal_valid_form() {
2830        assert!(validate_citation(&good_citation()).is_ok());
2831    }
2832
2833    #[test]
2834    fn validate_citations_rejects_count_over_cap() {
2835        let many = vec![good_citation(); 65];
2836        let err = validate_citations(&many).unwrap_err();
2837        assert!(format!("{err}").contains("too many"));
2838    }
2839
2840    #[test]
2841    fn validate_citations_propagates_first_invalid_entry() {
2842        let mut bad = good_citation();
2843        bad.uri = "bogus".to_string();
2844        let v = vec![good_citation(), bad];
2845        let err = validate_citations(&v).unwrap_err();
2846        assert!(format!("{err}").contains("must start with"));
2847    }
2848
2849    #[test]
2850    fn validate_citations_accepts_empty_and_full_under_cap() {
2851        assert!(validate_citations(&[]).is_ok());
2852        let v = vec![good_citation(); 64];
2853        assert!(validate_citations(&v).is_ok());
2854    }
2855
2856    // =================================================================
2857    // #966 — RequestValidator fluent-surface tests (Wave-2 Tier-C1)
2858    // =================================================================
2859
2860    fn happy_create() -> CreateMemory {
2861        // Reuse the same serde-default fixture pattern as cm_valid()
2862        // above so we don't depend on the private CreateMemory shape.
2863        serde_json::from_value(serde_json::json!({
2864            "title": "happy path",
2865            "content": "memory body",
2866            "namespace": "test-ns",
2867            "tags": [],
2868            "priority": 5,
2869            "confidence": 0.5,
2870            "source": "api",
2871            "metadata": {}
2872        }))
2873        .expect("happy_create fixture deserialises")
2874    }
2875
2876    #[test]
2877    fn request_validator_validate_create_happy_path() {
2878        // Happy path mirrors the legacy `validate_create` test
2879        // surface; ensures the facade is a 1:1 transparent wrap.
2880        let req = happy_create();
2881        assert!(RequestValidator::validate_create(&req).is_ok());
2882    }
2883
2884    #[test]
2885    fn request_validator_validate_create_rejects_empty_title() {
2886        // Each field-level reject path returns a ValidationError
2887        // whose `reason` mirrors the legacy bail!() string.
2888        let mut req = happy_create();
2889        req.title = String::new();
2890        let err = RequestValidator::validate_create(&req).expect_err("empty title must fail");
2891        assert!(
2892            err.reason.contains("title"),
2893            "reason should mention `title`: {}",
2894            err.reason
2895        );
2896        assert_eq!(err.field, "create");
2897    }
2898
2899    #[test]
2900    fn request_validator_validate_create_rejects_oob_confidence() {
2901        // Cross-field range gate: confidence=2.0 (out of 0..=1).
2902        let mut req = happy_create();
2903        req.confidence = Some(2.0);
2904        let err = RequestValidator::validate_create(&req)
2905            .expect_err("oob confidence must fail validation");
2906        assert!(
2907            err.reason.contains("confidence") || err.reason.contains("between"),
2908            "reason should mention confidence range: {}",
2909            err.reason
2910        );
2911    }
2912
2913    #[test]
2914    fn request_validator_validate_update_partial_ok() {
2915        // UpdateMemory is partial; empty update should validate
2916        // (no fields to check).
2917        let req: UpdateMemory =
2918            serde_json::from_value(serde_json::json!({})).expect("empty UpdateMemory deserialises");
2919        assert!(RequestValidator::validate_update(&req).is_ok());
2920    }
2921
2922    #[test]
2923    fn request_validator_validate_update_rejects_oob_priority() {
2924        let req: UpdateMemory = serde_json::from_value(serde_json::json!({
2925            "priority": 99,
2926        }))
2927        .expect("oob-priority UpdateMemory deserialises");
2928        let err =
2929            RequestValidator::validate_update(&req).expect_err("priority=99 must fail validation");
2930        assert!(
2931            err.reason.contains("priority") || err.reason.contains("between"),
2932            "reason should mention priority range: {}",
2933            err.reason
2934        );
2935    }
2936
2937    #[test]
2938    fn request_validator_validate_link_triple_happy_path() {
2939        assert!(RequestValidator::validate_link_triple("a-id", "b-id", "related_to").is_ok(),);
2940    }
2941
2942    #[test]
2943    fn request_validator_validate_link_triple_rejects_self_link() {
2944        // Cross-field rule: source_id == target_id is forbidden.
2945        let err = RequestValidator::validate_link_triple("same", "same", "related_to")
2946            .expect_err("self-link must fail");
2947        assert!(
2948            err.reason.contains("itself") || err.reason.contains("self"),
2949            "self-link must surface a typed reason: {}",
2950            err.reason,
2951        );
2952    }
2953
2954    #[test]
2955    fn request_validator_validate_link_triple_rejects_bad_relation() {
2956        let err = RequestValidator::validate_link_triple("a", "b", "BAD-CASE-RELATION")
2957            .expect_err("uppercase relation must fail");
2958        assert!(
2959            err.reason.contains("relation") || err.reason.contains("[a-z0-9_]"),
2960            "reason should mention relation: {}",
2961            err.reason,
2962        );
2963    }
2964
2965    #[test]
2966    fn request_validator_validate_consolidate_rejects_under_two_ids() {
2967        let err = RequestValidator::validate_consolidate(
2968            &["only-one".to_string()],
2969            "title",
2970            "summary body",
2971            "test-ns",
2972        )
2973        .expect_err("single id must fail");
2974        assert!(
2975            err.reason.contains("2"),
2976            "reason should cite the 2-id min: {}",
2977            err.reason
2978        );
2979    }
2980
2981    #[test]
2982    fn request_validator_validate_id_and_namespace_bundles_both() {
2983        // Happy: both fields valid.
2984        assert!(RequestValidator::validate_id_and_namespace("an-id", "a-ns").is_ok());
2985        // Reject path: invalid id surfaces first (id-then-ns ordering).
2986        let err = RequestValidator::validate_id_and_namespace("", "ok-ns")
2987            .expect_err("empty id must fail");
2988        assert_eq!(err.field, "id");
2989        // Reject path: valid id, invalid ns surfaces second.
2990        let err = RequestValidator::validate_id_and_namespace("ok-id", "")
2991            .expect_err("empty namespace must fail");
2992        assert_eq!(err.field, "namespace");
2993    }
2994
2995    #[test]
2996    fn request_validator_validate_owner_write_orders_id_ns_agent() {
2997        // Happy path.
2998        assert!(RequestValidator::validate_owner_write("an-id", "a-ns", "alice").is_ok());
2999        // Reject path: agent_id reserved sentinel surfaces last.
3000        let err = RequestValidator::validate_owner_write("an-id", "a-ns", "daemon")
3001            .expect_err("reserved agent_id must fail");
3002        assert_eq!(err.field, "agent_id");
3003        assert!(
3004            err.reason.contains("reserved"),
3005            "reserved-name reject must surface: {}",
3006            err.reason,
3007        );
3008    }
3009
3010    #[test]
3011    fn request_validator_validate_confidence_and_priority_bundles_both() {
3012        assert!(RequestValidator::validate_confidence_and_priority(0.5, 5).is_ok());
3013        let err = RequestValidator::validate_confidence_and_priority(2.0, 5)
3014            .expect_err("oob confidence must fail");
3015        assert_eq!(err.field, "confidence");
3016        let err = RequestValidator::validate_confidence_and_priority(0.5, 99)
3017            .expect_err("oob priority must fail");
3018        assert_eq!(err.field, "priority");
3019    }
3020
3021    #[test]
3022    fn request_validator_validate_agent_id_rejects_reserved_sentinel() {
3023        // Wire-side agent_id MUST reject the reserved set (issue #977).
3024        let err = RequestValidator::validate_agent_id("daemon")
3025            .expect_err("reserved daemon agent_id must be rejected");
3026        assert_eq!(err.field, "agent_id");
3027        assert!(err.reason.contains("reserved"));
3028    }
3029
3030    #[test]
3031    fn validation_error_into_anyhow_preserves_reason() {
3032        // The typed ValidationError must compose cleanly with the
3033        // anyhow-based call sites that haven't migrated yet.
3034        let ve = ValidationError::new("agent_id", "reserved for internal use");
3035        let ae: anyhow::Error = ve.into();
3036        assert!(format!("{ae}").contains("reserved for internal use"));
3037    }
3038
3039    #[test]
3040    fn validation_error_display_matches_legacy_bail_shape() {
3041        // Wire-side responses still parse `error.contains("namespace")`
3042        // — ensure the Display impl mirrors the legacy bail!() shape
3043        // verbatim (reason only, no field prefix).
3044        let ve = ValidationError::new("namespace", "namespace cannot be empty");
3045        assert_eq!(format!("{ve}"), "namespace cannot be empty");
3046    }
3047}