Memory features¶
Architecture
Normative spec
Five specs define the features on this page, all dated 2026-07-06 and all Approved:
core-engine/2026-07-06-memory-context.md(PR-089), Status: Approved, v0.3.core-engine/2026-07-06-memory-verify.md(PR-090), Status: Approved, v0.2.core-engine/2026-07-06-saved-answers-staleness.md(PR-088), Status: Approved, v0.3.app-layer/2026-07-06-memory-cards.md(PR-091), Status: Approved, v0.4 (OQ-1 write-action auth security countersigned).integration/2026-07-06-memory-watch.md(PR-087), Status: Approved, v0.4.
These five specs turn Telha from an answer system that waits to be asked into memory infrastructure that pushes, verifies, and expires on its own. This page covers what each one adds on top of the engine, not the engine itself.
Overview¶
"Memory" here is not one subsystem, it is five surfaces built on facts the core engine already stores: memory context exposes the generation planner's evidence selection without generation, memory verify exposes the claim-verification matcher against a caller-supplied claim instead of a generated draft, saved-answers staleness turns a generation trace into a durable object that watches its own citations for change, memory watch is the durable subscription primitive everything else in this group rides on, and memory cards is the chat rendering that turns a watch fire or a stale answer into a one-tap card. Three of the five (context, verify, watch) are independent Gateway-style additions; saved-answers depends on watch for change detection, and memory cards depends on both watch and saved-answers for what it renders.
As-built status
Every spec below is Approved. Only one of the five has implementing code in this repository: memory watch (core-engine/src/watch/mod.rs, 1343 lines, PR-087). Memory context, memory verify, saved-answers staleness, and memory cards are fully specified and ratified but have no corresponding module yet. Sections for those four describe the spec's normative design; there is no as-built deviation to report because there is no "as-built."
Memory context¶
POST /v1/memory/context (the Gateway's memory.context verb) exposes the memory planner's output as a first-class endpoint: the same span-level, budgeted, permission-scoped evidence set that /v1/generate would plan for the same input, returned without calling an LLM. It adds a surface, not a selection algorithm: one planner, two surfaces is the spec's controlling invariant (§3.1) . /v1/memory/context and /v1/generate MUST call the same planner entry point with the same recall legs (structured query, vector, traversal), dedup, scoring, per-source cap, and budget accounting. No context-specific selection logic is permitted to exist.
The request mirrors /v1/generate's planning inputs: question (semantic leg), optional structured find/where, a temporal window (validTime/txTime, full bitemporal addressing), a budget (maxTokens/maxSpans, clamped by server ceilings), and include flags (openQuestions, scores, stats). The response is span-grade, not prose: each item carries {text, source {sourceId, sourceVersionTx, start, end, name?}, score, validTime, pendingClarification?}. There is deliberately no summary field in v1: a natural-language summary would require generation and belongs to /v1/generate. Pending or conflicted clarifications on a cited node surface as openQuestions: [{clarificationId, state, question}], reusing the clarification caveat mechanism from temporal-clarification rather than inventing a second one.
Every call persists a kind: "context" trace (traces CF): request hash, plan summary (per-leg candidate counts, dedup drops, cap skips, stranded budget), and the emitted item coordinates, not span text (GET /v1/trace/:id decodes it via the existing generic-envelope reader). Permissions are unchanged from /v1/query: items are read through the same executor/read paths, so nothing reaches a caller they could not already query.
| Invariant | Statement |
|---|---|
| Parity | Identical (question, window, budget) yields the identical span-coordinate set and ordering /v1/generate would have used. |
| No egress | With no [llm] and no [embedding] configured, the endpoint still answers (vector leg skipped, reported in stats), zero outbound connections. |
| Honest truncation | Budget over ceiling is clamped and reported in stats.budget.applied, never an error. |
| Empty is an answer | A question with no relevant memory returns 200, empty items, honest stats, never a 500. |
{
"items": [
{
"text": "The liability cap is EUR 5,000,000.",
"source": {"sourceId": "d3f1...", "sourceVersionTx": 1730000000, "start": 412, "end": 468},
"score": 0.87,
"validTime": {"start": "2026-01-01T00:00:00Z"},
"pendingClarification": null
}
],
"openQuestions": [],
"stats": {"legs": {"query": 12, "vector": 40, "traversal": 3}, "candidates": 55, "dedupDropped": 8, "capSkipped": 2, "budget": {"requested": 2000, "applied": 2000, "used": 1840, "stranded": 160}},
"traceId": "a9c2..."
}
Metering is a dedicated telemetry namespace, separate from generation: context_calls, context_planner_ms, context_trace_writes, context_tokens_packed (context is cheaper than generation, but not free: the planner still runs and a trace still writes).
Memory verify¶
POST /v1/memory/verify (the memory.verify verb) exposes the claim-verification matcher (see Verified answers and claim-verification) against a caller-supplied claim instead of a claim decomposed from Telha's own draft. 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 claim verification, is not invoked on this path, so /v1/memory/verify needs no [llm] configuration.
Where this diverges from memory context is the honest point: embedding is required. Verification is geometric fusion, clamp01(cos)^β · lex^(1-β), and the cosine leg needs an embedding model. With none resolvable, the endpoint returns 422 VERIFY_UNAVAILABLE rather than silently degrading to a lexical-only score mislabeled as a verdict. Model consistency is enforced, not assumed: claim and candidate spans are embedded under one resolved model (verify.embed_model → request-named model → embedding.model); a request pinning an evidence set embedded under a different model is refused with the same VERIFY_UNAVAILABLE, because cosine across mismatched model spaces is meaningless.
Candidate retrieval is bounded vector search, not the budgeted planner: a standalone claim has no citations to seed from, so candidates are the top-m cosine-nearest spans in the RBAC- and temporally-scoped store, optionally narrowed by a structured against.find/where filter or pinned to explicit against.sourceIds. This is deliberately a different retrieval path than memory context's token-budgeted assembly, verify needs the best candidates to judge against, not a packed context window.
The verdict, bands, and numeric gate are claim-verification's, reused unchanged. An unsupported claim returns 200, verdict: "unsupported", with unsupportedReason present only when the verdict is unsupported, with fixed precedence:
| Precedence | unsupportedReason | Meaning |
|---|---|---|
| 1 | no_evidence_found | No candidate span survived retrieval/scoping. |
| 2 | numeric_or_date_mismatch | The whole-pair numeric gate capped the score against otherwise-overlapping evidence. |
| 3 | insufficient_support | Candidates were found but fell below the support band. |
interface VerifyResult {
claim: string;
verdict: "supported" | "partial" | "unsupported";
score: number;
unsupportedReason?: "no_evidence_found" | "insufficient_support" | "numeric_or_date_mismatch";
evidence?: Array<{ sourceId: string; sourceVersionTx: number; start: number; end: number; text: string; score: number }>;
signals?: { lexical: number; cosine: number; numericGate: "pass" | "capped" };
}
Telha does not emit a contradicted verdict in v1: that needs entailment/NLI the matcher does not provide, and the spec carries the known entity-swap limitation forward honestly (a claim with a swapped entity can still score supported; a canary test tracks it until a future NLI phase). Every call persists a kind: "verification" trace, metered in its own verify_* namespace (verify_calls, verify_embed_ms, verify_candidate_spans, verify_unavailable_count, verify_numeric_gate_capped_count), touching no LLM cost ledger.
Shared seam with saved-answers
The spec explicitly factors the score-a-claim-against-spans leg into a shared verify module entry point that both this endpoint and saved-answers' re-check (§3.4(iv) below) call, so the re-check logic is not duplicated. The verify_shared_seam success metric asserts the two callers produce identical verdicts for the same (claim, spans, model).
Saved-answers staleness¶
Saving an answer turns a /v1/generate trace into a durable graph node, label SAVED_ANSWER, that knows when the memory beneath it has moved. POST /v1/answers {traceId} creates the node from an existing generation trace; it never re-runs the planner. Its dependency set is exactly what the answer actually relied on: the cited evidence coordinates (source_id, source_version_tx, start, end) of each claim, plus any clarification ids pending on cited nodes at generation time. Saving auto- creates one internal memory watch (scope over the dependency set's source ids and clarification ids, target internal {job_kind: "check:answer"}, see Memory watch below); a change event enqueues a check:answer job for exactly the affected answers, with a periodic sweep (recheck_max_age_days, default 7) as the safety net, not the mechanism.
The re-check engine applies four rules per claim, in order:
- Evidence tombstoned → claim
invalidated. - Cited source version no longer the winning version for its span's validity (superseded, corrected, or steward-overridden) → claim
affected. - A clarification cited as pending has since
bound→affected;conflicted→conflicted; still open →pending. - If
affectedand an embedding model is available, re-run the claim-verification matcher (the shared seam from Memory verify) against the current winning spans:supportedclears the claim back took;partial/unsupportedkeeps itaffected. If the embedding model is unavailable, the claim staysaffectedwith averification:unavailablecaveat.
Answer status is a pure fold over claim states, precedence superseded > invalidated > conflicted > pending > stale > partially_stale > current:
flowchart TD
A[Any claim invalidated?] -->|yes| INV[invalidated]
A -->|no| B[Any claim conflicted?]
B -->|yes| CONF[conflicted]
B -->|no| C[Any claim pending?]
C -->|yes| PEND[pending]
C -->|no| D[ALL claims affected?]
D -->|yes| STALE[stale]
D -->|no| E[At least one claim affected?]
E -->|yes| PSTALE[partially_stale]
E -->|no| CUR[current] superseded is set only by refresh. Severity is a separate axis, never a status mutation: when the claim-verification numeric gate fires on an affected claim, the claim carries severity: material and the answer surfaces severity = max(claim severities), so the UI escalates presentation without the state machine losing its purity.
POST /v1/answers/:id/refresh re-runs /v1/generate with the stored question at current coordinates, saves the result as a new SAVED_ANSWER node, links a SUPERSEDES edge (new → old), and moves the old answer to superseded. Refresh creates, it never mutates: an answer quoted in a document must remain inspectable as quoted, and "keep original" is simply not refreshing. Refresh is always human-initiated in v1 (cost control, trust, audit clarity, and policy safety all argued against auto-refresh).
Transitions to a worse state emit answer_stale through the memory-watch internal seam (WatchOps::emit), carrying {answer_id, from, to, changed_claims: [idx]}; delivery, debounce, and permissions are entirely memory-watch's problem. SAVED_ANSWER properties (Value map, normal node versioning):
| Field | Type | Notes |
|---|---|---|
question, draft | string | Pinned at save time. |
trace_id | uuid | Must resolve to a kind:"generation" trace in the same tenant. |
time_basis_json | {validTime?, txTime} µs | The coordinates the generation planned at. |
status | enum | current \| partially_stale \| stale \| pending \| conflicted \| invalidated \| superseded. |
claims_json | array | Per claim: {idx, verdict_at_save, state, severity?, last_change_tx?, fire_id?}. fire_id links the WatchFire that triggered the re-check. |
severity? | material | Answer-level, max over claims. |
checked_at | µs | Last re-check time. |
Render-time permission safety (§3.10, a hard invariant)
A saved answer is never more visible than its underlying evidence and policy context allow at render time. The answer text itself, not just its citations, can reveal restricted information, so draft/question text must not post into shared channels unless channel-safe rendering passes. Per-answer ACLs are deferred; this invariant is not.
The check:answer job kind is internal-audience only: never leasable by worker, connector, or clarify audiences (a fence test asserts both directions). Re-checks never generate; the only model call on the re-check path is the §3.4(iv) matcher's embedding leg, so a tenant without embedding configuration still gets structural staleness from rules (i)-(iii).
Memory watch¶
Memory watch is the durable, tenant-scoped subscription primitive the other four features are built around, and the only one of the five with code in this repository (core-engine/src/watch/mod.rs). A watch subscribes to changes on an entity, label, source, stored query, clarification, or (reserved for saved-answers, created only via the internal seam) answer. It is evaluated asynchronously, after commit, from the existing append-only transaction-time index, the same covering index used by the global temporal scan, so there are no new write-path hooks and a watch bug cannot fail a write.
Scope, event kinds, and target are stored in a WatchDoc:
pub struct WatchDoc {
pub watch_id: Uuid,
pub owner: Owner, // person_id? or api_key_id?
pub scope_type: ScopeType, // Entity | Label | Source | Query | Clarification | Answer
pub scope_ref: String, // uuid | label | query-json, per scope_type
pub events: Vec<String>,
pub debounce_secs: u64,
pub cadence: Cadence, // Immediate | Hourly | Daily | Weekly
pub target: Target,
pub enabled: bool,
pub suspended: Option<Suspension>,
pub created_at: Ts,
pub last_fired_tx: Option<Ts>,
pub result_fingerprint: Option<u64>, // query watches only
}
KNOWN_EVENTS in the as-built code is fact_changed, fact_tombstoned, new_source_version, clarification_opened, clarification_bound, clarification_conflicted, query_results_changed, answer_stale. confidence_dropped from the product plan is deliberately absent: decay (see Confidence & decay) is a query-time computation, not a stored state transition, so it has no feed row to watch.
Target has no chat_channel variant at all:
pub enum Target {
ChatPerson { person_id: Uuid }, // DM to one bound person
ChatShared { audience: SharedAudience }, // fan-out to each authorised member's DM
Webhook { webhook_id: Uuid }, // operator-registered only
Internal { job_kind: String }, // e.g. saved-answers' check:answer
}
This is not an oversight, it is the spec's central permission argument: channel membership in Teams/Slack is administered outside Telha's RBAC and audit, so it is not an access boundary, and even entity names or counts posted to a channel would leak to unauthorised (and future) members. A shared/team watch therefore fans out to the DMs of its individually authorised members, each re-checked against the fire-time ACL/role gate; a member who fails the gate gets nothing. Literal channel posting is deferred to a future governed "cleared-channel" model (memory-watch OQ-5, explicitly not v1).
The evaluator sweep¶
WatchOps::sweep_at is the evaluator. On a tenant's first sweep (last_evaluated_tx == 0), it sets the checkpoint to the current watermark and processes no history, a fresh watch never fires on pre-existing state (no backfill storm). On subsequent sweeps it scans cf::TX_TIME_INDEX forward from the checkpoint, retains the oldest max_rows_per_tick rows (the contiguous frontier nearest the checkpoint; any excess resumes next tick and the outcome is marked backlog), reduces node rows to one entry per logical id at its latest change tx, reads each entity's current net state through NodeReads::get_current to classify fact_changed vs fact_tombstoned, and matches against every enabled, non-suspended watch's scope_type/scope_ref. Clarification, query, and source matching are explicitly deferred past the entity/label matching implemented in this slice (edges are counted toward the checkpoint but not yet classified).
Matches for the same watch across the swept window coalesce into one fire. The sweep commits the fire's WatchFire row, its notify:watch job, and the advanced checkpoint in one WriteBatch, so a crash between sweep and delivery re-runs at most the un-checkpointed segment and re-derives identical ids:
fn derive_id(prefix: &str, watch_id: Uuid, window: (Ts, Ts)) -> Uuid {
let tag = format!("{prefix}:{watch_id}:{}:{}", window.0, window.1);
Uuid::from_u128(xxhash_rust::xxh3::xxh3_128(tag.as_bytes()))
}
Fire and delivery-job ids are both derived this way, exactly-once feed consumption plus at-least-once delivery, and idempotent replay by construction.
sequenceDiagram
autonumber
participant W as Writer (NodeOps/EdgeOps)
participant IDX as tx-axis index
participant SW as WatchOps::sweep_at
participant WB as WriteBatch
participant J as notify:watch job
W->>IDX: version write (same batch)
Note over SW: ticker, per tenant
SW->>IDX: scan from checkpoint, oldest-first, capped at max_rows_per_tick
SW->>SW: classify vs enabled watches, coalesce per watch
SW->>WB: WatchFire row + notify job + checkpoint advance
WB-->>J: enqueued (leased only by clarify audience) The durable WatchFire record¶
Every fire is a durable product record, not just an audit line, because fires power stale-answer alerts, briefs, and "why was I notified" support questions and must survive delivery-job cleanup:
pub struct WatchFire {
pub fire_id: Uuid,
pub watch_id: Uuid,
pub fire_tx: Ts,
pub window: (Ts, Ts), // inclusive tx window this fire coalesced
pub events: Vec<FireEvent>, // kind, changed_ref_type, changed_ref_id, change_tx
pub truncated: bool,
pub delivery_target_kind: String,
pub delivery_job_id: Option<Uuid>,
pub status: FireStatus, // Fired | Delivered | Skipped{reason} | Suppressed
pub created_at: Ts,
}
FireEvent and the notify:watch job payload carry ids and kinds only, never property values or span text; rendering re-fetches whatever the recipient is authorised to see at delivery time, not at fire time. Fires are appended to a per-watch capped log (watch-fire/{watch_id}) and pruned oldest-first beyond fire_keep_per_watch, the audit sink remains the unbounded record.
Storage layout is a documented as-built deviation from spec §5
The spec sketches a (watch_id, fire_tx)-keyed WatchFire row directly in the schema CF. The schema CF's key is [scope][labelhash:8][tx:8], and a label is a hash, so it cannot prefix-enumerate a dynamic collection of ids. The code's module doc records the resolution: an enumerable watch/set document (BTreeMap<watch_id, WatchDoc>, capped at max_watches_per_tenant), a watch/checkpoint document, a watch/webhooks registry, and a per-watch watch-fire/{watch_id} log accessed only by known id. Every write is still an append-only new version of the affected document, so the audit trail is the row history exactly as retention does it.
Configuration¶
[watch] (core-engine/src/config.rs, WatchConfig):
| Key | Default | Meaning |
|---|---|---|
enabled | false | Runs the evaluator ticker from the serving process. |
eval_interval_secs | 60 | Seconds between ticks. |
max_watches_per_tenant | 1000 | Create returns WATCH_LIMIT (409) at the cap. |
max_rows_per_tick | 50_000 | Bounded sweep; backlog resumes next tick. |
debounce_default_secs | 3600 | Default coalescing window. |
debounce_floor_secs | 60 | Hard floor on any watch's window. |
max_events_per_fire | 100 | Cap on events per fire; excess sets truncated. |
query_watch_max_results | 1000 | Row ceiling for a query watch's result fingerprint. |
suspend_after_gate_denials | 5 | Consecutive delivery-gate denials before auto-suspend. |
webhook_timeout_secs | 10 | Webhook delivery timeout. |
webhook_max_attempts | 3 | Attempts before dead-letter + suspend. |
fire_keep_per_watch | 1000 | Durable WatchFire rows kept per watch. |
Invariants asserted by the code's own test suite¶
- Fresh start skips history (
fresh_start_skips_history): a pre-existing entity does not fire on the first sweep; only the checkpoint advances. - Coalescing (
label_watch_fires_coalesces_and_enqueues): two matching changes inside one sweep window produce exactly one fire with two events, and enqueue exactly onenotify:watchjob. - No refire after checkpoint advances (
no_refire_after_checkpoint_advances): a second sweep with no new changes fires nothing. - Tenant isolation (
tenant_isolation): a change in tenant A's scope never reaches tenant B's watch, proven directly againstTenantScope. - Fire pruning is oldest-first and newest-first on read (
fires_append_prune_and_order): capped atfire_keep_per_watch, andWatchOps::firesreturns newest first. - Webhook targets must be registered (
webhook_target_must_be_registered): an unregisteredwebhook_idis rejected at watch creation, closing the arbitrary-egress path the spec's alternatives section rules out. - Debounce is clamped, never rejected (
debounce_clamped_to_floor): a request belowdebounce_floor_secsis silently raised to the floor.
Configuration (memory context, memory verify, saved-answers)¶
These three ship as spec-defined [section] blocks; none has a corresponding module in this repository yet, so the table below is the normative configuration, not an as-built confirmation.
| Section | Key | Default | Notes |
|---|---|---|---|
[context] | enabled | true | Read-only, no egress; a kill switch for operators keeping the gateway dark. |
[context] | max_tokens_ceiling | 8192 | Server ceiling; requests are clamped, not rejected. |
[context] | max_spans_ceiling | 64 | |
[context] | default_max_tokens | 2000 | |
[context] | trace | true | Global flag in v1 (no per-tenant override). |
[verify] | endpoint_enabled | true | Inert without an embedding model (VERIFY_UNAVAILABLE), by design. |
[verify] | max_claims_per_request | 32 | |
[verify] | candidate_max_spans_ceiling | 50 | |
[verify] | default_candidate_spans | 20 | |
[verify] | endpoint_trace | true | |
[answers] | enabled | false | Requires watch.enabled; a boot warning fires if answers is on without watch. |
[answers] | recheck_max_age_days | 7 | Safety-net sweep age, not the primary mechanism. |
[answers] | max_saved_per_tenant | 10_000 | |
[answers] | recheck_batch_size | 50 | Answers per re-check job. |
[answers] | reverify_on_affected | true | The §3.4(iv) matcher leg. |
[memory_cards] | brief_max_lines | 25 | Explicit "+N more" line, never a silent truncation. |
[memory_cards] | refresh_confirm | true | [Refresh answer] always confirms; it spends generation budget. |
[memory_cards] | action_token_ttl_secs | 120 | Lifetime of the person-scoped core.card_write_action token. |
Memory cards¶
Memory cards is the app-layer rendering of the two events the core engine produces above: a memory-watch fire and a saved-answer answer_stale transition. It defines three Telha-initiated chat cards, Memory Change, the Weekly Memory Brief, and Saved Answer Updated, plus a one-tap [Watch this] action added to the existing ask-answer card. It owns templates, actions, and the notify:watch consumer wiring in clarify-bot; delivery, signing, identity binding, and idempotent send are chat-delivery-surface's, reused unchanged.
The delivery rule is the same one memory-watch enforces at the core: every card is DM-only. A chat_person target renders to one RBAC-checked person's DM; a chat_shared target fans out to each authorised member's DM. No card of any kind has a channel-post code path in v1, so the leak the problem statement worries about is structurally impossible rather than policy-forbidden.
clarify-bot's PollJobs set gains notify: alongside clarify: (fenced so no other audience may lease notify:watch). Delivery idempotency rides a new NotifyDeliveryEntity {fire_id, recipient, message_ref, nonce, kind, status, created_at}, the same shape as the existing clarification delivery entity, keyed by fire_id (the durable WatchFire id from memory watch) instead of clarification_id; a shared/team fire produces one NotifyDeliveryEntity per resolved-and-authorised recipient, so fan-out cannot double-send.
New card actions are additive discriminated actions riding the existing {kind, ..., nonce} schema: watch_create ([Watch this]), answer_refresh ([Refresh answer], always behind a confirm dialog because it spends generation budget), and watch_mute ([Mute]/[Stop watching]). Write-actions do not execute under the tenant service token: the bot mints a short-lived, single-use, person-scoped core.card_write_action token, and core alone performs the final authorization check.
{
"tenant": "...",
"person_id": "...",
"action_kind": "watch_create",
"target_id": "...",
"scope_fingerprint": "...",
"generation_id": "...",
"aud": "core.card_write_action",
"jti": "<single-use-id>",
"exp": "now + ~120s"
}
Core rejects the token if it is expired, aud does not match, the jti has already been used (replay), the action_kind/target_id do not match the requested write, the person has lost the required authority, the payload was tampered with (scope_fingerprint mismatch), or the write exceeds the card's originally permitted action. Blast radius of a leaked or replayed token: one person, one action, one target, once, about two minutes.
Brief dedup is per recipient: the Weekly Memory Brief collapses fires to one line per (changed_ref_id, change_tx) even when an entity watch, a label watch, and a query watch all fired on the same underlying change, because the brief is a rendering fold over durable WatchFire rows, not a second source of truth. Severity (from saved-answers) drives presentation only, a severity: material transition escalates the header, it never changes who is notified; that stays entirely the watch's cadence.
Related¶
- The version record model - why saved-answer status transitions get an append-only audit trail for free.
- Global temporal scan - the tx-axis covering index that memory watch sweeps as its change feed.
- Tombstone cascade / GDPR erasure
- how a tombstoned or erased dependency reaches a saved answer's
invalidatedstate and its GDPR surface-register row. - Verified answers - the claim-verification matcher that both memory verify and saved-answers' re-check call through a shared seam.
- Clarifications - the pending/bound/conflicted lifecycle that both memory context's
openQuestionsand saved-answers' re-check rules consume. - Claim verification - decomposition, matching, and verdict scoring in full (memory verify exposes this engine directly).
- Clarification engine - routing and quorum (source of the
clarification_opened/bound/conflictedevent kinds memory watch subscribes to).