Skip to content

Spec: Memory Verify Endpoint — Claim Support Without Generation

File: .ai/specs/core-engine/2026-07-06-memory-verify.md Status: Approved (2026-07-06, J. Porter — all four OQs resolved; the OQ-2 unsupportedReason refinement is incorporated. Claim-only, generation-free, embedding-required, model-consistent, three-verdict with an explicit unsupported reason, honest about the lexical-semantic-numeric limitation) Owners: Core lane; reviewer: J. Porter Related: PR-090 (this spec), claim-verification spec (PR-052 — the matcher / lexical / numeric-gate / verdict-band engine this EXPOSES; no scoring logic is re-specified here), memory-context spec (PR-089 — sibling planner-family read verb; shares candidate retrieval + the no-generation/parity discipline), memory-planner spec (candidate retrieval reuses vector partition search), generation-orchestration spec (the decompose step is the ONLY LLM path, optional and gated), saved-answers-staleness spec (PR-088 §3.4(iv) re-check IS this matcher leg — shared code), mcp-tools spec (memoryVerify tool addendum), gdpr-erasure spec (verification traces are already GenerationTrace-class rows in the surface register)


1. Overview

Exposes PR-052's claim-verification engine as a first-class Gateway verb: POST /v1/memory/verify answers "is this claim supported by current memory?" for a caller-supplied claim (or claims), returning a verdict (supported | partial | unsupported), the evidence spans that support it with coordinates, the underlying signals, and a persisted trace — without generating anything. It is the agent-and-application twin of the human "prove it" loop: an external system can hand Telha a statement and get back an evidence-grounded judgment plus the provenance to act on it. This spec owns the endpoint, its request/response contract, candidate retrieval for a standalone claim, and the model-consistency guarantee; all scoring (geometric fusion, IDF lexical coverage, the whole-pair numeric gate, verdict bands) is PR-052's and is reused unchanged.

2. Problem Statement

PR-052's verification engine is reachable only inside /v1/generate, where it scores the claims of a draft Telha itself produced. But the verb agents and applications actually want — "we have a statement (from a vendor doc, a CRM field, another model's output); does our memory support it?" — has no surface. Concretely: (a) an agent about to act on "the renewal is 24 months" cannot ask Telha to confirm it against evidence before proceeding; (b) a compliance flow that must check a supplier's assertion against source documents has to fabricate a generation round-trip to reach the matcher, paying for and discarding a draft; (c) the matcher already exists and is adversarially tested (number-swap, planted-falsehood canaries), yet its judgment is trapped behind generation; (d) saved-answer re-check (PR-088 §3.4(iv)) re-runs exactly this matcher leg — without a shared endpoint/module seam, the logic risks being duplicated. The gap is purely surface: the verdict machinery is built; nothing lets a caller submit a claim and get a grounded answer.

3. Proposed Solution

Normative decisions:

  1. Claims in, not a draft — so no generation on the primary path. The request carries one or more discrete claim strings. The matcher scores each against retrieved evidence directly; the LLM decompose step (the only generative part of PR-052) is NOT invoked. /v1/memory/verify therefore requires no [llm] configuration and performs no generation egress — the same "no-LLM" posture as memory.context.
  2. Embedding IS required (the honest difference from memory.context). Verification is geometric fusion clamp01(cos)^β · lex^(1−β) (claim-verification §5) — the cosine leg needs an embedding model. With no embedding model resolvable, the endpoint returns 422 VERIFY_UNAVAILABLE rather than silently degrading to a lexical-only verdict (a weaker, differently-calibrated judgment mislabeled as verification). This is claim-verification's own stance ("verification:unavailable when the claim model can't match the evidence model") surfaced as an API error.
  3. Model consistency is enforced, not assumed. The claim and every candidate span are embedded AT VERIFY TIME under ONE model, resolved by PR-052's chain (verify.embed_model → request-named model → embedding.model). A request that pins an evidence set embedded under a different model is refused with VERIFY_UNAVAILABLE — cosine across mismatched model spaces is meaningless, and structural consistency is the guarantee that keeps verdicts sound.
  4. Candidate retrieval is bounded vector search, not the budgeted planner. A standalone claim has no citations to seed from, so candidates = the top-m cosine-nearest spans in the RBAC- and temporally-scoped store (partition search, memory-planner machinery), optionally narrowed by a structured against.find/where filter or pinned to explicit against.sourceIds. Verify does not run the token-budgeted assembly planner (memory.context's job) — it needs the best candidate spans to judge against, not a packed context window.
  5. The verdict, bands, and gates are PR-052's, unchanged. Fusion, β, IDF lexical coverage over plan-local spans, the whole-pair numeric gate (a number/date mismatch caps the pair at 0.2 → drives unsupported/partial), and the verdict bands (supported ≥ partial + 0.10) are reused exactly. No verification tuning lives in this spec.
  6. An unsupported claim is an answer, not an error. A claim with no supporting evidence returns 200 with verdict: unsupported and empty evidence — Telha's "I don't know / not supported" discipline. Empty memory never produces a 500.
  7. Known limitation carried forward, honestly. PR-052 records entity-swap as an NLI-needing v1 limitation (a claim with a swapped entity can score supported). /v1/memory/verify inherits it: the response and docs state that verification is lexical-semantic-numeric, not entailment, and a canary test asserts the limitation so it stays tracked (Phase 4 NLI). Callers are told what the verdict does and does not prove.
  8. Every call persists a kind:"verification" trace (traces CF, same envelope discipline as query/context traces): request hash, resolved model, per-claim verdict + evidence coordinates + signals. GET /v1/trace/:id decodes it generically (PR-052's reader already tolerates unknown kinds). Verification, like context, is metered in its own telemetry namespace and touches no LLM cost ledger (nothing is generated).

4. Architecture

POST /v1/memory/verify ─▶ auth (api-key | tenant bearer)         [existing middleware]
   resolve embed model (verify.embed_model → request → embedding.model)
     └─ none? ─▶ 422 VERIFY_UNAVAILABLE
   for each claim:
     candidates = against.sourceIds (pinned)  OR  top-m cosine spans
                  in RBAC + temporal scope (partition search, +find/where)   [PR-050/039]
     embed claim + candidate spans under the ONE model  ─ consistency check
     matcher: fuse(clamp01(cos)^β, lex^(1−β)) + numeric whole-pair gate       [PR-052 verify]
     verdict = band(score)   (supported | partial | unsupported)
   persist kind:"verification" trace ─▶ {results[], traceId, modelUsed, stats}
MCP memoryVerify ──▶ same handler, shaped envelope
saved-answers re-check (PR-088 §3.4iv) ──▶ SAME matcher module seam

5. Data Models

No new storage schema. Request DTO (camelCase): {claims: [string] (1..max_claims), against?: {sourceIds?: [uuid], find?: …, where?: … (query grammar)}, window?: {validTime?, txTime?}, candidates?: {maxSpans?: u16}, include?: {evidence?: bool (true), signals?: bool (true), trace?: bool (true)}}. Response DTO: {results: [{claim, verdict: "supported"|"partial"|"unsupported", score, unsupportedReason?: "no_evidence_found"|"insufficient_support"|"numeric_or_date_mismatch", evidence?: [{sourceId, sourceVersionTx, start, end, text, score}], signals?: {lexical, cosine, numericGate: "pass"|"capped"}}], traceId, modelUsed, stats: {candidatesPerClaim, embeddedSpans, vectorLegScoped?}}. unsupportedReason is present ONLY when verdict = "unsupported" (OQ-2 resolution) — it explains why a claim failed without overclaiming contradiction, with fixed precedence: no_evidence_found (no candidate span survived retrieval/scoping) > numeric_or_date_mismatch (the whole-pair numeric gate capped the score) > insufficient_support (candidates found but below the support band). Telha does NOT emit a contradicted verdict in v1 — that needs entailment/NLI the matcher does not provide (deferred with the entity-swap limitation). Verification trace record (msgpack, traces CF): {v:1, kind:"verification", request_hash, model, window, claims: [{text_hash, verdict, score, unsupported_reason?, evidence_coords: [(source_id, source_version_tx, start, end)], signals}]} — coordinates + scores only, no span-text duplication (re-resolved via canonical_slice, the getTrace convention). Invariants: verdict bands and the numeric-gate cap are PR-052's constants (not redefined here); evidence spans are always within the caller's RBAC scope (retrieved through the scoped read path); signals.numericGate = "capped" exactly when the whole-pair numeric mismatch fired, which is also exactly when unsupportedReason = "numeric_or_date_mismatch" if the banded verdict is unsupported.

6. API Contracts

POST /v1/memory/verify — at least one claim required (400 VERIFY_INVALID, pointered via the shared serde_path_to_error convention). 422 VERIFY_UNAVAILABLE when no embedding model resolves, or a pinned evidence set was embedded under a different model (message names the mismatch). 200 with verdict: unsupported + empty evidence when memory has nothing (not an error). When verdict = "unsupported" the response carries unsupportedReason (OQ-2): no_evidence_found = no candidate span survived retrieval/scoping (empty evidence); numeric_or_date_mismatch = the whole-pair numeric gate capped the score against otherwise-overlapping evidence (evidence returned so the caller sees the conflicting number/date); insufficient_support = candidates were found but did not meet the support band. Precedence when more than one could apply: no_evidence_found > numeric_or_date_mismatch > insufficient_support. against.sourceIds restricts evidence to those sources (verify against THESE); absent → open cosine retrieval in scope. Budget/breadth: candidates.maxSpans clamped by candidate_max_spans_ceiling (clamp reported in stats, never an error); claims length clamped by max_claims_per_request. gRPC: NOT added in v1 (matches generate/context REST-only posture; recorded follow-up on SDK demand). SDKs: TS + Python gain memory.verify(...). MCP addendum (mcp-tools): memoryVerify {claim, against?, validTime?} → shaped result (standard truncation envelope; max_field_chars applies to evidence text). Draft-in decomposition (submit a paragraph, decompose to claims, then verify) is explicitly deferred (OQ-1) — it would reintroduce the [llm] dependency this endpoint exists to avoid.

7. UI/UX

Developer/agent-facing; no end-user UI. This PR ships telha memory verify "<claim>" [--against-source <id>] [--valid-at <ts>] (prints verdict + score + supporting spans with coordinates — doubles as the verification debug tool), and the trace is inspectable via the existing GET /v1/trace/:id + MCP getTrace. The quickstart gains a "verify a claim" example alongside the memory.context one, completing the Gateway-verb walkthrough (ask / context / verify / compare). Copy follows house rules; the verdict vocabulary matches the human trace card (supported/partial/unsupported) so agent output and chat output read identically.

8. Configuration

Reuses [verify] (bands, β, embed_model) from PR-052 — no matching knobs are added or re-tuned here. Adds [verify] endpoint keys: endpoint_enabled (true — read-only, embedding-only, no generation egress, low new risk; a kill switch for operators keeping the Gateway dark), max_claims_per_request (32), candidate_max_spans_ceiling (50), default_candidate_spans (20), endpoint_trace (true). Metering is a dedicated verify_* namespace (verify_calls, verify_embed_ms, verify_candidate_spans, verify_unavailable_count, verify_numeric_gate_capped_count) separate from generate and context, mirroring memory.context's telemetry decision — strong telemetry now so PR-086b can prove whether per-tenant controls are ever needed (OQ-3). Deliberately NOT configurable: embedding-required (no lexical-only fallback), model consistency, no-generation on the claims-in path, verdict bands/gate (PR-052 owns them).

9. Alternatives Considered

  • Reuse /v1/generate with a "verify-only" flag. Rejected: generate takes a question and produces a draft; verify takes claims and produces judgments. Overloading generate muddies auth/cost/failure semantics and forces a decompose/LLM path the claims-in contract does not need. A distinct verb is the product (memory.verify).
  • Lexical-only verdict when no embedding is configured. Rejected: fusion is cosine-based; a lexical-only score is a different, weaker judgment. Returning it under the same verdict field would mislabel it as verification. Fail loudly with VERIFY_UNAVAILABLE instead.
  • Client-side verification (SDK computes the match). Rejected: the matcher, IDF over corpus spans, and the numeric gate are core logic, and candidate evidence is RBAC-scoped server-side — a client cannot see the spans it would need, nor should it.
  • Run the full budgeted planner for candidate retrieval. Rejected: token-budgeted assembly is memory.context's job; verification needs the best candidate spans to judge against, not a packed window. Bounded cosine top-m (+ optional structured scope) is the right retrieval.
  • Add NLI/entailment now to fix entity-swap. Deferred (§3.7): PR-052 scoped entity-swap out as a Phase-4 NLI limitation; adding a model here would fork verification quality from generate's. When NLI lands, it lands in the shared verify module for BOTH surfaces.
  • Accept a draft and decompose it here. Deferred to OQ-1: useful ("verify this whole vendor paragraph") but it needs [llm] and belongs behind an explicit flag, not on the no-LLM primary path.

10. Implementation Approach

One small core PR (PR-090), spec first, after PR-089 (shares the request/DTO conventions and the candidate-retrieval seam) and independent of PR-087/088: request DTO + validation → embed-model resolution + consistency guard → candidate retrieval (pinned sources OR bounded cosine, RBAC/temporal-scoped) → invoke the PR-052 matcher module (factor the score-a-claim-against-spans leg into a shared verify entry point that both this endpoint and PR-088's re-check call) → verdict + trace persistence → REST + OpenAPI derive + router-mirror test entry → CLI + MCP tool + SDK methods → §12 tests. The only PR-052-adjacent change is exposing the matcher leg as a callable seam (no scoring change); saved-answers (PR-088) then consumes the same seam instead of duplicating it.

11. Migration Path

Purely additive: new route, new trace kind (generic reader already tolerant), new MCP tool, additive SDK methods, a shared verify-module seam that PR-088 reuses. No storage, wire, or config migration. Ships endpoint_enabled = true but is inert without an embedding model (returns VERIFY_UNAVAILABLE, honestly). Forward compatibility: response gains fields additively; the deferred draft-in mode (OQ-1) and future NLI both slot in without breaking the claims-in contract; trace record carries {v:1}.

12. Success Metrics

  • verify_bands: a claim strongly supported by a span verdicts supported; a partially-overlapping claim partial; an unrelated claim unsupported — bands exactly PR-052's (supported ≥ partial + 0.10), asserted on seeded evidence.
  • verify_numeric_swap_capped: a claim identical to a span except a swapped number/date has its pair capped at 0.2 and verdicts unsupported/partial (the numeric gate, reusing PR-052's canary).
  • verify_no_llm_required: the claims-in path returns correct verdicts with NO [llm] config and zero generation egress (egress harness from generation-orchestration); only the embedding endpoint is exercised.
  • verify_embedding_required: with no embedding model resolvable, the endpoint returns 422 VERIFY_UNAVAILABLE rather than a lexical-only verdict.
  • verify_model_consistency: a pinned evidence set embedded under a different model than the claim is refused with VERIFY_UNAVAILABLE; claim + candidate spans are always embedded under one resolved model.
  • verify_pinned_sources: against.sourceIds restricts evidence to those sources; a claim supported only by an unpinned source verdicts unsupported under the pin.
  • verify_bitemporal: verifying at a past coordinate uses as-known-then evidence (fixture mirrors the compare/eval "as known in June vs September" pattern); future versions excluded.
  • verify_empty_unsupported: a claim with no candidate evidence returns 200 unsupported + empty evidence + honest stats, never a 500.
  • verify_unsupported_reason_no_evidence: a claim with no scoped candidates returns unsupported with unsupportedReason: no_evidence_found and empty evidence.
  • verify_unsupported_reason_insufficient_support: a claim with related but sub-band evidence returns unsupported with unsupportedReason: insufficient_support, and the candidate evidence when include.evidence = true.
  • verify_unsupported_reason_numeric_mismatch: a claim with high lexical/cosine overlap but a swapped number/date verdicts unsupported/partial as banded, and — when the final verdict is unsupported — carries unsupportedReason: numeric_or_date_mismatch with the conflicting span returned.
  • verify_entity_swap_limitation: a canary asserts the KNOWN limitation (an entity-swapped claim may score supported) so it stays tracked until Phase-4 NLI — the honest counterpart to PR-052's entity-swap canary.
  • verify_trace_and_isolation: every call persists a kind:"verification" trace whose coordinates re-resolve to the returned evidence via canonical_slice; cross-tenant evidence never appears; MCP output obeys the truncation envelope.
  • verify_shared_seam: PR-088's saved-answer re-check and this endpoint produce identical verdicts for the same (claim, spans, model) — the shared-module invariant (asserted once the seam exists).

13. Open Questions

  • OQ-1: Draft-in decomposition? Resolved 2026-07-06 (J. Porter): deferred. The v1 contract stays claim in → retrieval → matcher verdict → trace out, generation-free. A paragraph-decompose path reintroduces [llm], adds cost behaviour, and blurs generation vs verification; it belongs behind an explicit future {text, decompose: true} mode that requires [llm], traces the decomposition step separately, and makes clear Telha generated the intermediate claims before verifying them.
  • OQ-2: Should unsupported distinguish "contradicted" from "no evidence"? Resolved 2026-07-06 (J. Porter): yes, but NOT via a new verdict. Keep three verdicts (supported|partial|unsupported) — a contradicted verdict would imply entailment/NLI the matcher cannot safely guarantee while entity-swap is a known limitation. Instead add unsupportedReason (no_evidence_found | insufficient_support | numeric_or_date_mismatch), present only when verdict = unsupported (§5/§6), giving applications the practical distinction without overclaiming. True contradicted deferred to the NLI phase.
  • OQ-3: Per-tenant rate limits for verify? Resolved 2026-07-06 (J. Porter): global ceilings (max_claims_per_request, candidate_max_spans_ceiling, default_candidate_spans) + strong telemetry in v1 (verify_calls, verify_embed_ms, verify_candidate_spans, verify_unavailable_count, verify_numeric_gate_capped_count); per-tenant controls arrive through the admin/operator layer only once the data shows noisy tenants, high embedding cost, abuse, or enterprise quota needs.
  • OQ-4: Return raw score components by default? Resolved 2026-07-06 (J. Porter): yes — high-level signals (lexical, cosine, numericGate) are part of the product: a caller asking Telha to judge a claim must see the basis of the judgment. Deeper diagnostics (rejected candidates, per-candidate ranking, token/phrase coverage, IDF contribution, numeric/date mismatch details, full matcher audit) stay behind a future explain: true mode — not returned by default.

14. Changelog

  • 2026-07-06 — v0.2: Approved — all four OQs resolved by J. Porter, one refinement incorporated. OQ-2 gains unsupportedReason (no_evidence_found | insufficient_support | numeric_or_date_mismatch, present only when verdict = unsupported, fixed precedence) so applications get the "why not" without a contradicted verdict the matcher can't safely guarantee (that stays deferred to the NLI phase) — §5 DTO, §6 semantics, §12 three new metrics, trace record unsupported_reason. OQ-1 (draft-in decompose deferred to an explicit {text, decompose:true} [llm] mode), OQ-3 (global ceilings + telemetry verify_unavailable_count/verify_numeric_gate_capped_count added; per-tenant limits admin-layer, evidence-driven), OQ-4 (signals returned by default as the verdict's justification; deeper diagnostics behind future explain:true) resolved as drafted. Status → Approved.
  • 2026-07-06 — v0.1: initial draft (PR-090). Exposes the PR-052 matcher as POST /v1/memory/verify (the Gateway memory.verify verb): claims-in / no-generation, embedding-REQUIRED (the honest difference from memory.context — cosine fusion needs a model; no lexical-only fallback), enforced model consistency, bounded cosine candidate retrieval (RBAC/temporal-scoped, pinnable to sources), PR-052 verdict bands + numeric gate reused unchanged, kind:"verification" traces, dedicated verify_* telemetry, the entity-swap NLI limitation carried forward honestly, and a shared matcher seam that PR-088's saved-answer re-check consumes. Draft-in decomposition and a contradicted verdict deferred (OQ-1/OQ-2).