Spec: Saved Answers & Staleness — Answers That Know When They Expire¶
File: .ai/specs/core-engine/2026-07-06-saved-answers-staleness.md Status: Approved (2026-07-06, J. Porter — all OQs resolved in v0.2; ratified with the Phase-3d set once the memory-cards OQ-1 security countersign cleared the last gate. Severity a separate axis; render-time permission safety a hard invariant §3.10; refresh human-initiated) Owners: Core lane; reviewer: J. Porter Related: PR-088 (this spec), memory-watch spec (PR-087 — staleness alerts ride the internal delivery target; REQUIRED dependency), claim-verification spec (re-check reuses the matcher + verdict bands), generation-orchestration spec (refresh re-runs /v1/generate), memory-planner spec (trace plan = the dependency source), temporal-clarification spec (pending/conflicted states come from clarification lifecycle), gdpr-erasure spec (answer text joins the surface register), mcp-tools spec (saveAnswer/listSavedAnswers addendum), chat-delivery-surface spec (PR-091 renders the Saved Answer Updated card)
1. Overview¶
Turns generated answers into durable, first-class memory objects that know when the memory beneath them has moved. A saved answer pins a generation trace (question, draft, per-claim evidence coordinates); a re-check engine re-evaluates those coordinates against current memory and drives a staleness state machine (current → partially_stale / stale / pending / conflicted / invalidated / superseded); transitions emit answer_stale events through memory-watch so owners are told proactively. This spec owns the saved-answer object, the dependency model, the re-check rules, the state machine, refresh semantics, and the audit trail. It is the "this answer was correct when generated, but the memory has changed" feature — the product's strongest differentiator over stateless chat.
2. Problem Statement¶
Today an answer from /v1/generate is a trace row: fully auditable, but frozen. Concretely: (a) a "Meridian exposure summary" pasted into a board pack silently rots when the liability cap is corrected from €2m to €5m — Telha knows the fact changed and says nothing; (b) a pending clarification that later binds can invalidate the interpretation an answer relied on, and no surface connects the two; (c) users re-ask the same expensive question defensively because they cannot trust yesterday's answer; (d) traces already carry exact per-claim evidence coordinates (source_id, source_version_tx, span) — the dependency information exists and is unused; (e) without a defined state machine, any ad-hoc "is this stale?" check would give inconsistent verdicts across surfaces. Nothing in the platform can currently say which claims of which answers are affected by a change — the loop from correction back to consumers of the corrected knowledge is open.
3. Proposed Solution¶
Normative decisions:
- A saved answer is a graph node (label
SAVED_ANSWER), created explicitly from an existing generation trace. Node versioning gives status transitions an append-only audit trail for free; answers are queryable, comparable, and erasable through every existing mechanism. Properties: question, draft text,trace_id, time basis (the valid/tx coordinates the generation planned at), status, per-claim check summary,checked_at. - Dependencies come from the trace, never re-derived. The dependency set of a saved answer is exactly: 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 (already recorded aspendingClarificationcaveats). Saving an answer MUST NOT re-run the planner — the object pins what the answer actually relied on. - Saving auto-creates one internal watch (memory-watch scope over the dependency set's source ids + clarification ids, target
internal { job_kind: "check:answer" }). A change event enqueues a re-check job for exactly the affected answers. A periodic sweep (recheck_max_age_days) is the safety net, not the mechanism. - Re-check rules (per claim, in order): (i) evidence tombstoned → claim
invalidated; (ii) cited source version no longer the winning version for its span's validity (superseded by newer version, correction, or steward-override binding) → claimaffected; (iii) a clarification cited as pending has sincebound→ claimaffected; has becomeconflicted→ claimconflicted; still open → claimpending; (iv) if the claim isaffectedand the verify embed model is available, re-run the claim-verification matcher against the CURRENT winning spans — verdictsupportedclears the claim back took(the change didn't touch what the claim asserts),partial/unsupportedkeeps itaffected; if the model is unavailable, keepaffectedwith averification:unavailablecaveat (structural signal stands, consistent with claim-verification's stance). - Answer status is a pure fold over claim states, precedence
superseded > invalidated > conflicted > pending > stale > partially_stale > current: any claim invalidated →invalidated; else any conflicted →conflicted; else any pending →pending; else ALL claims affected →stale; else ≥1 affected →partially_stale; elsecurrent.supersededis set only by refresh (§3.6). Status math is deterministic — two re-checks over the same store agree. Severity is a separate axis, never a status mutation (OQ-2 resolution): when the claim-verification numeric gate fires on an affected claim (a materially different number), the claim carriesseverity: materialand the answer surfacesseverity = max(claim severities)alongsidestatus— the UI escalates presentation, the state machine stays pure. - Refresh creates, never mutates.
POST /v1/answers/:id/refreshre-runs/v1/generatewith the stored question at CURRENT coordinates, saves the result as a new SAVED_ANSWER node, linksSUPERSEDES(new → old), and moves the old answer tosuperseded. The old answer, its trace, and its claim history remain intact — append-only doctrine applies to answers exactly as to facts. "Keep original" is simply not refreshing. - Transitions to a worse state emit
answer_stalethrough 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 (owner DM card rendered by PR-091). Transitions back towardcurrent(a correction reverted) emit the same event kind with the improving direction — consumers decide whether to notify. - Re-checks never generate. The re-check engine calls no LLM except the §3.4(iv) matcher leg, which embeds claim/span text via the existing verify path — same models, same caps, same cost ledger. A tenant without embedding config still gets structural staleness (rules i–iii).
- Saved answers are tenant data: they flow through backup, retention (label-selector policies apply), compare, and GDPR erasure (draft text is subject-bearing — PR-081's surface register gains a row, same class as GenerationTrace draft text).
- Render-time permission safety is a hard invariant (OQ-3 resolution): a saved answer is never more visible than its underlying evidence and policy context allow at render time. v1 visibility: owner-attributed, tenant-visible under normal RBAC, evidence links enforce source permissions — but the ANSWER TEXT itself can reveal restricted information, so draft/question text MUST NOT post into shared channels unless channel-safe rendering passes (PR-091 obligation: audience-aware safe summary, not just hidden source links). Per-answer ACLs are deferred, the invariant is not.
4. Architecture¶
POST /v1/generate ──▶ GenerationTrace (claims + evidence coords) [existing]
POST /v1/answers {traceId} ──▶ SAVED_ANSWER node (v1 status: current)
└─▶ internal watch over dep set (PR-087)
change event ──▶ check:answer job ──▶ re-check engine
per claim: tombstone? winner moved? clarification bound/conflicted/open?
└─ affected + embed available ─▶ verify matcher vs current spans
fold claim states ──▶ new node version (status + check summary + checked_at)
worse transition ──▶ WatchOps::emit(answer_stale) ──▶ owner DM / webhook
POST /v1/answers/:id/refresh ──▶ /v1/generate now ──▶ new node + SUPERSEDES edge
5. Data Models¶
SAVED_ANSWER node properties (Value map, normal node versioning): question (string), draft (string), trace_id (string uuid), time_basis_json ({validTime?, txTime} µs), status (enum string §3.5), claims_json (per-claim: {idx, verdict_at_save, state: ok|affected|pending|conflicted|invalidated, severity?: material, last_change_tx?, fire_id?} — fire_id links the WatchFire that triggered the re-check, severity set when the numeric gate fired), severity? (answer-level, max over claims), checked_at (µs), created_by_person/created_by_key (attribution), title?. Edge SUPERSEDES (new → old) with standard edge versioning. The dependency set is NOT duplicated onto the node — it is read from the trace at re-check time (single source of truth; traces are already durable). Internal watch document carries answer_id in its scope_ref so check:answer payloads are self-routing. Invariants: trace_id must resolve to a kind:"generation" trace in the same tenant at save time; claim indexes in claims_json are stable against the trace's claim array; status transitions append versions, never in-place edits (storage guarantees this by construction).
6. API Contracts¶
REST (API-key or tenant-bearer): POST /v1/answers {traceId, title?} → 201 with the node id (404 TRACE_NOT_FOUND, 409 ALREADY_SAVED if a live SAVED_ANSWER already pins the trace, 422 TRACE_NOT_GENERATION for query traces); GET /v1/answers[?status=] (executor-backed listing, label-scoped); GET /v1/answers/:id → answer + per-claim states + for each affected claim the changed evidence refs (old vs current winning version coords) — the "view changed claims" payload; POST /v1/answers/:id/check (synchronous re-check, for CLI/console and tests); POST /v1/answers/:id/refresh → 202 with new answer id (requires [llm] configured; taxonomy mirrors /v1/generate incl. cost-cap refusals); DELETE /v1/answers/:id (tombstone via existing cascade; detaches the internal watch). Job kind check:answer is internal-audience only (never leasable by worker/connector/clarify audiences — fence test). MCP addendum (mcp-tools): saveAnswer {traceId}, listSavedAnswers {status?} shaped by the standard envelope; refresh deliberately NOT exposed over MCP in v1 (generation cost behind a chat tool needs the PR-091 card confirmation flow first).
7. UI/UX¶
PR-091 renders the Saved Answer Updated card (owner DM: old/new status, changed-claim bullets with evidence links, [Refresh answer] [View changed claims]; "Keep original" = dismiss). This PR ships: telha answers ls|show|check|refresh|rm CLI (show prints per-claim states + changed evidence coords), and the mercato trace viewer gains a "Save answer" affordance + status chip (small app-layer follow-up recorded in PR-091's scope). Every transition, re-check, refresh, and suppressed notification is a structured audit event.
8. Configuration¶
[answers]: enabled (false; requires watch.enabled — boot warning if answers on without watch, staleness then runs on the sweep only), recheck_max_age_days (7 — safety-net sweep age), max_saved_per_tenant (10_000), recheck_batch_size (50 answers/job), reverify_on_affected (true — the §3.4(iv) matcher leg). Deliberately NOT configurable: the state precedence order, dependency-from-trace-only, append-only refresh semantics, the internal-audience fence on check:answer, re-checks-never-generate.
9. Alternatives Considered¶
- Staleness computed lazily at read time (no stored state, evaluate on GET). Rejected: proactive alerts are the feature — "Telha tells you" beats "Telha would have told you if you'd asked". Lazy evaluation also re-does the same work per reader with no audit trail. (GET does return the stored state +
checked_at, so readers can judge freshness.) - Dependencies denormalized onto the answer node. Rejected: duplicating evidence coordinates invites divergence from the trace; traces are already durable, tenant-scoped, and exactly the provenance record auditors will be shown.
- Auto-saving every generated answer. Rejected: most generations are exploratory; auto-save creates tenant-cap pressure, GDPR surface growth, and alert noise for answers nobody relies on. Saving is an explicit act of reliance.
- Refresh mutates the existing answer in place. Rejected: an answer quoted in a document must remain inspectable as quoted; supersession chains mirror the store's own correction semantics (and
comparethen works on answers too). - A dedicated staleness daemon polling all answers on a schedule. Rejected as the mechanism: O(answers) work per cycle regardless of change volume; the watch-driven path does work proportional to actual changes. (The bounded sweep remains as the safety net for missed events.)
- Full re-plan (re-run planner) per re-check. Rejected: re-planning answers "what would I cite today", not "did what I cited move" — it is refresh, not staleness detection, and costs planner work per answer per change.
10. Implementation Approach¶
One core PR (PR-088), after PR-087, spec first: SAVED_ANSWER model + save/list/get/delete over existing NodeOps/executor → dependency extraction from traces + internal watch creation → check:answer job + re-check engine (rules §3.4, fold §3.5) → matcher re-verify leg → refresh over the existing generate path + SUPERSEDES edge → REST/CLI/MCP + §12 tests. PR-091 renders cards. Crash safety: re-check writes the new node version and the emit in one logical step per answer (job retry is idempotent — same store state folds to the same status).
11. Migration Path¶
Greenfield and additive: new node label + edge type (schema inference handles both), new REST routes, new job kind, one mcp-tools addendum. Ships answers.enabled = false. Existing traces are immediately saveable — no backfill needed or performed. claims_json carries {v:1} for forward evolution; older saved answers remain foldable if new claim states are added (unknown states fail closed to affected).
12. Success Metrics¶
answer_stale_on_correction: save an answer, correct a cited fact (new version) → answer becomespartially_stale, the changed claim is identified with old vs new evidence coords,answer_staleemitted exactly once per debounce window.answer_current_on_untouched_change: a change to a cited SOURCE that does not alter the winning span text re-verifiessupportedand the answer stayscurrent(the §3.4(iv) clear-back leg).answer_pending_and_conflicted: clarification on a cited node →pending; conflicted clarification →conflicted; binding resolves topartially_staleor back tocurrentper re-verify.answer_invalidated_on_tombstone: cascade-tombstoning cited evidence →invalidated, precedence beating other claim states.answer_refresh_supersedes: refresh creates a new answer at current coordinates, links SUPERSEDES, old →superseded; old trace + claim history untouched (byte-compare).answer_recheck_idempotent: re-running the same check job produces no new version and no duplicate emit when nothing changed sincechecked_at.answer_no_generate_on_check: re-check with a poisoned LLM endpoint succeeds structurally (only the embed path may be exercised); with no embedding config, rules i–iii still fold correctly with the caveat recorded.answer_fence_and_isolation:check:answerunleasable by worker/connector/clarify audiences; tenant A cannot save, list, or refresh against tenant B's traces or answers.answer_gdpr_erasure_row: erasing a subject whose data appears in a saved answer masks the draft/question per the PR-081 executor (register row proven by test once PR-081 lands; until then the surface is documented).
13. Open Questions¶
OQ-1: ShouldResolved 2026-07-06 (J. Porter): always human-initiated in v1 — cost control (LLM budget on answers nobody may read), trust (users see the change and choose), audit clarity (the old answer remains the answer produced at its time), UX simplicity (alert first, regenerate on click), and policy safety (a regenerated answer may have a different access/policy context). Future opt-in candidates recorded for later phases (recurring board briefs, low-risk internal summaries) — never as default.staletrigger an automatic refresh when the tenant opts in?OQ-2: Should a numeric-only change escalateResolved 2026-07-06 (J. Porter): no — the status fold stays structurally pure; materiality surfaces as the separatepartially_staletostale?severityaxis (§3.5, §5) so the UI escalates presentation without the numeric gate mutating global answer state.OQ-3: Per-answer ACLs before channel-shared briefs cite answers?Resolved 2026-07-06 (J. Porter): per-answer ACLs deferred, but the render-time invariant is pinned NOW as §3.10 — an answer is never more visible than its evidence and policy context allow at render time; answer TEXT (not just citations) can reveal restricted information, so PR-091 must render audience-safe summaries, not merely hide source links.
14. Changelog¶
- 2026-07-06 — v0.3: Approved (J. Porter). Ratified with the Phase-3d set once the memory-cards OQ-1 write-action security countersign cleared the last gate; no design change from v0.2. Buildable after PR-087 (memory-watch, also Approved).
- 2026-07-06 — v0.2: OQ review by J. Porter — all three resolved as drafted, two refinements incorporated. (1) Severity as a separate axis (§3.5, §5): claim + answer
severity: materialwhen the numeric gate fires — presentation escalates, the status fold stays pure. (2) Render-time permission safety pinned as hard invariant §3.10: answer text never posts to shared channels unless audience-safe rendering passes (PR-091 obligation beyond hiding citations); per-answer ACLs stay deferred. (3) Auto-refresh confirmed human-initiated with the five review reasons recorded; future opt-in candidates noted, never default. Also:claims_jsongainsfire_idlinking the WatchFire that triggered each re-check (pairs with memory-watch v0.2's durable fire records). Status → In Review. - 2026-07-06 — v0.1: initial draft (PR-088). Answers as SAVED_ANSWER nodes over pinned traces; dependency-from-trace-only; watch-driven re-check with the four-rule engine + deterministic status fold; matcher-based clear-back leg; append-only refresh via SUPERSEDES;
answer_stalethrough the memory-watch internal seam; GDPR surface row recorded.