Job
An agent asked “why did you suggest that?” can trace the answer back to the memories that informed it, and from each memory to where it came from. The server returns that chain as data: ids, kinds, actors and times. It never writes the explanation or a summary of the chain. Synthesis stays with the client, per the July 2026 positioning decision the issue cites. The agent already holds the first link: the ids of the memories it used, fromsearch, briefing or handoff. This page designs the second: given a memory id, what made it.
What exists today
Most of the chain is already stored, and REST can read most of it. MCP clients can follow one link of it.
The issue’s list of what already exists needs three corrections:
review_proposalshas four kinds, not three. Retention proposals from the forgetting pass also archive and supersede memories.- No production code stages a fact proposal today. The table, the list and the accept path ship, but
insertFactProposals(packages/db/src/fact-proposals.ts) has no caller outside tests. Resolution and retention proposals come only from default-off workers (SESSION_CLOSER_ENABLED,FORGETTING_PASS_ENABLED). - An accepted proposal leaves no pointer to itself on what it produced. An edge accept, and a retention supersede, insert a
memory_edgesrow whosecreated_byis the reviewer’s actor (user_mcpover MCP), the same value areviseedge carries (packages/db/src/proposals-apply.ts). When that edge issupersedesorupdates, the accept also writes a payload-lesssupersedeevent on the predecessor. A fact accept writes afactsrow with no proposal column and no audit event (packages/db/src/fact-proposals-apply.ts). Resolution and retention archive accepts writeresolveorarchiveevents whose payload names no proposal. What survives is the proposal row:applied, with its memory ids anddecided_at.
1. Chain contents
A traced memory gets five sections. Every field is an id, enum, boolean or timestamp, plus the identity fields the history read already returns (memoryType, topic, scope, project, status, validity). The chain carries no content, tags or raw payload, and no proposal rationale: that is model-written text, and returning it would put server-written prose into the chain.
The producer links (
sourceProposal, proposalId, produced, sourceProposalId) exist only if decision 3 records them at accept time. Without them the chain can list applied proposals, but it cannot say what each one produced.
Three rules hold across sections:
- Nothing is inferred. A value is reported as recorded, or as
null. The history read already works this way: in a revision chain each version reports its own creating run, and nothing is inherited from neighbouring events. Matching an applied proposal to an edge or fact by equal ids or times would break this rule and name the wrong producer in two shipped cases. An edge accept that finds the edge already present flips the proposal toappliedwithout creating the edge. A crash between a resolution claim and its transition leaves anappliedproposal with noresolveevent. - One level per call. A predecessor appears as an identity row with its creating run, not with its own proposals and events. To go further, the client traces the predecessor’s id. The client walks and the server bounds.
- Deterministic order. Lineage by depth, then
createdAt, thenid; events and proposals bycreatedAt DESC, id DESC. These are the keys the shipped reads use, and theidtie-breaker keeps the subset under a cap stable.
sessionRunId is null for an imported memory and for a write without provenance, as in the history read today. run is null when the id does not resolve to a session row for the caller; the id is still returned as recorded.
2. Surface
A. Aget_memories include option (recommended). Add include: ["provenance"], and each found item gains an optional provenance object with the sections above. get_memories is the “read what I found” tool, and the agent asking why already holds the ids from search or briefing. Output schemas advertise open, so a client on a stale catalog keeps working when items grow a field. AGENTS.md rule 8 applies to a changed tool too. The job, tracing where a memory came from, is one no tool covers today, and the description change means the generated reference, eval/fixtures/transport-surfaces.json and the tool-selection embeddings are regenerated and the gate floors re-run. No tool and no tool-selection scenarios are added. A report-only capabilityScenarios entry (get_memories#provenance, like revise#disposition) would show whether agents find the option. It revises one shipped sentence in item 11 of MCP design: “One hop only; lineage stays with REST history.”
B. A new trace_memory tool. It gets its own contract and size budget, and get_memories stays unchanged. It needs a JTBD no tool covers (“where did this memory come from”), exactly 5 scenarios in eval/fixtures/tool-selection.json, regenerated surfaces, and the max_description_overlap ceiling (0.6723 today) still holding. The risk is the one rule 8 names: both tools take memory ids and return per-memory detail, so trace_memory would read like get_memories.
C. REST history only. Extend GET /api/v1/memories/{id}/history with proposals, facts and run, and leave MCP unchanged. This fails the job, because the agent that is asked works over MCP and cannot reach REST. A resource template such as threengram://memory/{id}/provenance reaches only hosts that surface resources. That limit is why describe_environment inlines the facet inventory.
Recommendation: A, built on one packages/core read that both get_memories and the REST history route call, so agents and the dashboard see the same chain and each transport only maps fields (rule 5).
3. Bounds
Depth. Only the traced memory gets all five sections. Lineage keeps the shipped cycle-safe walk with its limits (MEMORY_HISTORY_LINEAGE_NODE_LIMIT 25, MEMORY_HISTORY_LINEAGE_EDGE_LIMIT 50) and its truncated flag. No section recurses: predecessors, proposals and runs are one hop from the traced memory.
Page size. Each section has a cap and reports truncated: events at the shipped 50, proposals and facts at 25 each as starting values. With provenance included, the ids limit drops from 20 to 5, and a test pins the worst-case response under a named ceiling, as one pins the get_memories content budget today. The first cut has no cursor. A memory with more than 50 events is pathological, and the flag says so instead of hiding it.
Tenant isolation on every hop.
- One
withTenant(userId)transaction inpackages/dbreads the whole chain. No code outsidepackages/dbtouches the pool (rule 3). - Every statement carries
user_id = $userId, bound to the caller, with RLS as the second layer (data model, two-layer tenant isolation). That includes the recursive step of the lineage walk and each proposal-table read. Inpackages/db/src/memory-history-queries.ts, the direct-relationship read binds to the target row’s tenant rather than the caller’s, and all other shipped history queries rely on RLS alone (#427). The chain read must not copy that. - Memory-to-memory, memory-to-event, memory-to-fact and memory-to-proposal hops are composite
(user_id, …)foreign keys, so a cross-tenant link cannot be stored. - The hops without a foreign key are
payload->>'sessionRunId'toagent_sessionsand, if decision 3 adds it,payload->>'proposalId'to a proposal table. The write path refuses another tenant’s run id (layer 2), but the read does not rely on that: both hops join on(user_id, id). - An unknown or foreign memory id lands in
notFound, as inget_memoriestoday, so a trace cannot probe whether an id exists. - Logs, traces and metrics carry ids, counts, section status and truncation flags only (rule 6). The chain returns
topicto its owner, so a handler never logs a chain object.
4. Eval
Howeval/ is built today:
- The blocking gate (
eval/src/run.mjs) runs in-memory cosine over committed embeddings, with no network, no database and one corpus.golden-set.jsonrows carryid,type,topic,content,replacesandcreated.queries.jsonrows carry aslice, a query andexpected/forbiddenids.floors.jsonholds floors and ceilings that only ratchet. - Tenant isolation is not an eval slice. It is
scripts/ci-smoke-app.sql(real RLS, runtime role) and the integration suites (testing). - The forgetting pass shows the pattern for a harness the gate cannot host: a report-only runner, a small committed synthetic mirror, and a private golden set outside the repository.
- Fixture
eval/fixtures/provenance-synthetic.json: a scripted scenario for tenants A and B, written through the product write path. It coversrememberwith asessionRunId, arevisechain longer than 25 versions, a move, an archive, a reaffirm, and one accepted proposal of each kind. B mirrors A’s topics and shapes, so a leak returns plausible rows. Rows use labels such asa.decision.v3, mapped to created ids at seed time, and each probe lists its expected labels per section. - Metrics:
provenance_chain_recall(expected labels returned, over expected), floor 1.0;provenance_chain_precision(returned ids that were expected, over returned), floor 1.0;provenance_foreign_ids(ids owned by B in any section of a trace run as A: memories, edges, events, proposals, facts and runs), ceiling 0. The long chain must reporttruncated: true. - Synthetic only. A cross-tenant check needs two tenants, and the anonymized production memories come from one account.
Decisions for the owner
- Surface: A (
get_memoriesinclude), B (new tool) or C (REST only). Recommended: A. run.agent: include the run’s harness label, which reverses the history read’s choice not to project it. The label is client-supplied:agentSessionOpenBodySchemarequiresagentin the body ofPOST /api/v1/agent-sessions/open, normally sent by the hook, and any kebab-case name passes. It is bound to the tenant but never verified. Layer 2 only stops a write from naming its own agent. Recommended: include it, documented as reported by the session opener, never as verified attribution.- Proposal links: record the producing proposal at accept time, or list applied proposals per memory without saying what they produced. Recording means typed references, each a composite
(user_id, …)FK (rule 3):source_fact_proposal_idonfacts, and onmemory_edgesbothsource_consolidation_proposal_idandsource_retention_proposal_id, with a CHECK that at most one is set. Each referenced proposal table gains the(user_id, id)unique key thatmemorieshas. Theresolveandarchiveevents a resolution or retention accept writes get an allowlistedproposalIdpayload key, whose event kind names its table. Asupersedeevent’s producer is its edge’s reference. Existing rows staynulland are never backfilled. Inference by join is not offered, for the reason in section 1. Recommended: record. - Bounds: 5 ids with provenance, 25 proposals and 25 facts per memory, no cursor in the first cut. All are starting values.
- Eval runner: a blocking test in the integration suite (the
integrationjob, gated bytest), an advisory nightly lane like the real-retriever mode, or a new DB-backed step ineval-gate. Recommended: the integration suite, with the fixture ineval/fixtures/. - History read predicates: add caller-bound
user_idpredicates to the shipped history queries as a separate fix first, or in the same build as the chain read. Tracked as #427.