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:
- 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
decomposestep (the only generative part of PR-052) is NOT invoked./v1/memory/verifytherefore requires no[llm]configuration and performs no generation egress — the same "no-LLM" posture asmemory.context. - Embedding IS required (the honest difference from
memory.context). Verification is geometric fusionclamp01(cos)^β · lex^(1−β)(claim-verification §5) — the cosine leg needs an embedding model. With no embedding model resolvable, the endpoint returns422 VERIFY_UNAVAILABLErather 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. - 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 withVERIFY_UNAVAILABLE— cosine across mismatched model spaces is meaningless, and structural consistency is the guarantee that keeps verdicts sound. - Candidate retrieval is bounded vector search, not the budgeted planner. A standalone claim has no citations to seed from, so candidates = the top-
mcosine-nearest spans in the RBAC- and temporally-scoped store (partition search, memory-planner machinery), optionally narrowed by a structuredagainst.find/wherefilter or pinned to explicitagainst.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. - 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. - An unsupported claim is an answer, not an error. A claim with no supporting evidence returns
200withverdict: unsupportedand empty evidence — Telha's "I don't know / not supported" discipline. Empty memory never produces a 500. - 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/verifyinherits 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. - 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/:iddecodes 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/generatewith 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
verdictfield would mislabel it as verification. Fail loudly withVERIFY_UNAVAILABLEinstead. - 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 verdictssupported; a partially-overlapping claimpartial; an unrelated claimunsupported— 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 verdictsunsupported/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 returns422 VERIFY_UNAVAILABLErather than a lexical-only verdict.verify_model_consistency: a pinned evidence set embedded under a different model than the claim is refused withVERIFY_UNAVAILABLE; claim + candidate spans are always embedded under one resolved model.verify_pinned_sources:against.sourceIdsrestricts evidence to those sources; a claim supported only by an unpinned source verdictsunsupportedunder 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 returns200unsupported+ empty evidence + honest stats, never a 500.verify_unsupported_reason_no_evidence: a claim with no scoped candidates returnsunsupportedwithunsupportedReason: no_evidence_foundand empty evidence.verify_unsupported_reason_insufficient_support: a claim with related but sub-band evidence returnsunsupportedwithunsupportedReason: insufficient_support, and the candidate evidence wheninclude.evidence = true.verify_unsupported_reason_numeric_mismatch: a claim with high lexical/cosine overlap but a swapped number/date verdictsunsupported/partialas banded, and — when the final verdict isunsupported— carriesunsupportedReason: numeric_or_date_mismatchwith 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 akind:"verification"trace whose coordinates re-resolve to the returned evidence viacanonical_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: ShouldResolved 2026-07-06 (J. Porter): yes, but NOT via a new verdict. Keep three verdicts (unsupporteddistinguish "contradicted" from "no evidence"?supported|partial|unsupported) — acontradictedverdict would imply entailment/NLI the matcher cannot safely guarantee while entity-swap is a known limitation. Instead addunsupportedReason(no_evidence_found | insufficient_support | numeric_or_date_mismatch), present only whenverdict = unsupported(§5/§6), giving applications the practical distinction without overclaiming. Truecontradicteddeferred 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-levelsignals(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 futureexplain: truemode — 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 whenverdict = unsupported, fixed precedence) so applications get the "why not" without acontradictedverdict the matcher can't safely guarantee (that stays deferred to the NLI phase) — §5 DTO, §6 semantics, §12 three new metrics, trace recordunsupported_reason. OQ-1 (draft-in decompose deferred to an explicit{text, decompose:true}[llm]mode), OQ-3 (global ceilings + telemetryverify_unavailable_count/verify_numeric_gate_capped_countadded; per-tenant limits admin-layer, evidence-driven), OQ-4 (signalsreturned by default as the verdict's justification; deeper diagnostics behind futureexplain: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 Gatewaymemory.verifyverb): 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, dedicatedverify_*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 acontradictedverdict deferred (OQ-1/OQ-2).