basis field, no basis on any tool input or output, and no change to the supersede rule. Tracked as issue #409. Sebastian agreed on 2026-11-03 that the idea is worth pursuing (recorded in the issue). Every option below stays open until the owner decides; see Decisions for the owner.
The job: a reader can tell a stated fact from an inferred belief, and how sure the writer was, before acting on it. The owner wrote “weight is 98 kg”; an extractor only inferred a status from prose. Today get_facts returns both as the same kind of row.
This page uses three bases:
- stated: the owner said it, in their own words or by confirming it.
- observed: the writer read it from a source it can name, such as a file, a tool result or a device export.
- inferred: the writer concluded it from other evidence, such as prose, a pattern or an estimate.
What exists today
Each claim cites the code it rests on, as of the commit that added this page.- Confidence on writes.
confidencein [0, 1] is optional onrememberfacts (packages/schema/src/write.ts:157) and on import (packages/schema/src/import.ts:185), stored in a nullablerealcolumn (packages/db/src/schema/memory.ts:246). It has bounds and no meaning: the generatedrememberreference lists only its type and range (docs/reference/tools.mdx:88-92). No engine code acts on it: it is stored, copied, exported and returned, and the CLI prints it (apps/cli/src/format.ts:47). - Confidence on reads.
get_factsreturnsconfidenceas nullable (packages/schema/src/mcp.ts:509). It does not return the row’smemoryId(apps/server/src/mcp/tools.ts:332-344, RESTapps/server/src/rest/router.ts:595-604), so a reader cannot get from a fact to the memory that asserted it. - Who wrote a fact. A fact row has no writer column (
packages/db/src/schema/memory.ts:235-264). The writer is recorded only as theactor_kindof the source memory’s audit event:createon a native write (packages/db/src/memory-write.ts:207-214),importwith actorimporteron an import (packages/core/src/import/index.ts:7-9). Every MCP write recordsuser_mcp(apps/server/src/mcp/tools.ts:252, session continuity), whether the owner dictated the value or the agent inferred it. - Extracted-fact proposals.
fact_proposalsstores the extractor’s confidence (packages/db/src/schema/memory.ts:328), the review list returns it (packages/schema/src/review-fact-proposals.ts:46), and accepting copies it onto the new fact (packages/db/src/fact-proposals-apply.ts:103-112). The accept passes no actor and writes no memory event (packages/core/src/admin/proposals.ts:288), and the fact’smemory_idis the source memory. The fact row alone cannot tell an accepted extraction from a direct write; only the applied proposal row records it. - Review gate.
get_factsselects fromfacts, never fromfact_proposals(packages/db/src/facts-read.ts:307), and accepting is the only path from a proposal into it (MCP design item 9). Nothing extracted becomes queryable before review. - No extractor ships.
insertFactProposals(packages/db/src/fact-proposals.ts:112) has no caller outside tests. The extractor is issue #228, still open. Issue #409’s “proposals carry the extractor’s confidence” describes the table and the accept path, not running behaviour: no shipped code creates a proposal. - Supersede (#369). Since engine v1.11.0 a fact key is single-valued by default: a write closes every open value of its key that started at or before it, at its own start (
packages/db/src/facts-write.ts:241-252, memory model). A row markedcardinality: "many"or written with its ownvalidTocloses nothing (facts-write.ts:108). The rule reads neither basis nor confidence. Accepting a proposal writes a single-valued fact (fact-proposals-apply.ts:103), unless the proposal has its ownvalidTo. A proposal withoutvalidFromstarts at accept time (fact-proposals-apply.ts:110), so it closes the current value. Import opts out (packages/db/src/memory-import.ts:247). Current reads hide facts whose parent memory was superseded or archived (packages/db/src/facts-read.ts:153-159). This is engine behaviour. The managed service does not run it yet; its production rollout waits on a separate evaluation. searchandbriefing. Neither readsfacts; both return memories (packages/db/src/search.tsandpackages/db/src/briefing-read.tsname the table only in comments). The briefing’s stale candidates includefact-type memories (packages/core/src/read/briefing.ts:108-113), which is a memory type, not a fact row.- Calibration precedent. The forgetting harness computes expected calibration error (ECE) over 10 equal-width bins (
eval/src/forgetting-eval.mjs:65,:179). The forgetting pass reports it beside precision and does not make it a bar.
1. Basis: a stored field or derived from the writer
A. Derive it. No new column. The data can tell a direct write from an accepted proposal, by joining the applied proposal on memory and triple, and usually a native write from an import, by the source memory’s audit event. Only usually: an imported fact writes no event of its own and may ride any memory the tenant owns (packages/db/src/memory-import.ts:241-249). It cannot tell stated from inferred on the main path, because the owner and the agent write through the same user_mcp actor. For an accepted extraction it credits whoever wrote the source memory, not whoever confirmed the value, because the accept records no actor. Derivation answers “which path”, not “what basis”.
B. Store a writer-declared basis per fact. A nullable basis column on facts, whose enum (stated, observed, inferred) lives in packages/schema with a generated CHECK (hard rule 2). It is optional on remember facts and on import, and carried through export. The remember input is a surface change under hard rule 8: it needs a regenerated snapshot (pnpm run docs:generate plus eval/fixtures/transport-surfaces.json). It is written once, at insert, and never updated (hard rule 1). The accept path sets the basis itself, to stated: accepting is the owner confirming the value, which is how stated is defined, and it is what keeps a later inferred write from replacing that value without review under 2B. The applied proposal row still records that the value began as an inference, and the copied confidence stays the extractor’s score, as today. The alternative keeps inferred and adds a reviewed marker that the supersede rule treats as grounded, which says more at the cost of a second column. Either way, acceptance confirms only as much as the review gate does: review_proposals accepts as user_mcp too (apps/server/src/mcp/tools-admin.ts:304). A missing basis is stored as null and read as unknown, and nothing is backfilled. Correcting a basis is a new assertion: an owner who confirms an inferred value writes it again as stated, which closes the inferred row at its own start under the single-valued rule.
C. B plus a server-set write path (direct, proposal, import). A column replaces the join in A, at the cost of a second field to carry through export and import.
Basis belongs on the fact, not the memory: one memory can state one value and infer another, and the memory types enum is out of scope. A declared basis is the writer’s claim, like confidence; the server can vouch for it only on the accept path.
Recommendation: B, with an accepted proposal recorded as stated. Add C’s path only if a reader needs it; the applied proposal row already records it.
2. Supersede: may an inferred value close a stated one?
Group the bases in two tiers: grounded (stated, observed) and inferred.
A. Basis-blind, as today. The newest start wins whatever its basis. Simple and already in the engine, but an agent’s estimate silently replaces the owner’s number in current reads, and the stated value survives only in range and asOf history.
B. Inferred closes only inferred; a conflict goes to review. A grounded write closes any open value, as today. An inferred direct write closes open inferred values, but when the key has an open grounded value it is staged as a fact proposal instead, for the owner to accept or reject. Accepting any fact proposal, extracted or staged, closes under the normal rule and records stated (question 1), because the owner said yes. This reuses the review gate, keeps one open value per key and only appends. Its costs:
- It catches only writers that volunteer
inferred. Basis is the writer’s own claim, and the owner and the agent both write asuser_mcp(apps/server/src/mcp/tools.ts:252). An agent that declaresstatedstill closes the owner’s value, and so does one that omits basis if unknown counts as grounded (see below). B stops estimates that honest writers label as such; it is not a security boundary. - It covers supersede-eligible rows only. An inferred value marked
cardinality: "many"or written with its ownvalidTocloses nothing (facts-write.ts:108), so it is never staged, and amanyvalue stays open beside the grounded one. - It covers
get_factsonly. The memory’s prose still asserts the staged value, sosearchandbriefingkeep returning that memory while its fact waits for review. - Surface and layering.
remembermust report a fact it staged instead of wrote: an additive output field, so a regenerated snapshot under hard rule 8.fact_proposalsgains a second source besides the extractor. The staging decision lives inpackages/coreorpackages/db, never in a transport (hard rule 5).
facts-write.ts:249-252), so a backdated stated value lands as history below a newer open inferred one; the owner restores it by writing it without validFrom. And confidence plays no part in closing (question 4).
An unknown basis needs a precedence without a label. It covers every fact written before basis ships, and every later write that omits it. This choice sets how much of B’s gate survives (decisions 3 and 4):
- U1. Unknown counts as grounded everywhere. Protects existing values, but a new write that omits
basisis grounded too, so omitting it bypasses the gate. - U2. Unknown counts as grounded only for rows written before
basisships. The cutoff compares the fact’screated_at, which every path sets on the server (packages/db/src/schema/memory.ts:250;facts-write.ts:140-154never writes it), notrecorded_at, which import replays from the export (packages/schema/src/import.ts:188). A new write that omitsbasiscounts as inferred, both when it would close a value and when a later write would close it. So does a row imported after the cutoff without a basis, even when itsrecorded_atis older: restoring an old export drops the protection its rows had. Omission no longer bypasses the gate, and a writer can still declarestated. The cost lands on today’s clients. None sendsbasisyet, so every update they make to a key whose open value predates the cutoff is staged for review instead of written, including an owner’s own dictated value sent through an older plugin. U2 changes behaviour for every current caller until it sendsbasis. - U3. Unknown counts as inferred everywhere. Keeps today’s behaviour for existing values and protects none of them.
3. Reads: filter, rank or label
get_facts: label. Returnbasis(nullable) on every fact, on MCP and REST alike. An output field is an additive widening that a caller parsing a subset ignores, asrecordedAtwas (packages/schema/src/mcp.ts:496). It is still a surface change under hard rule 8 and needs a regenerated snapshot (pnpm run docs:generatepluseval/fixtures/transport-surfaces.json).- No ranking. For a single-valued key a current read returns one value, so there is nothing to rank. A
cardinality: "many"key keeps every value open (packages/db/test/integration/facts-write.int.test.ts:263-272), andlimitapplies after ordering (packages/db/src/facts-read.ts:291-293,:317), so the order decides which of those values a bounded result keeps. Keep the shipped order for them too:recorded_atin list mode,valid_fromin range mode. Putting grounded values first would silently change which values existing callers lose at the limit. Narrowing to the subject and predicate still returns at most 200 values (MAX_FACTS_LIMIT,packages/schema/src/mcp.ts:380,:482), andget_factshas no cursor, so a largermanykey cannot be read in full through this surface. Full reads would need pagination, a separate surface change under hard rule 8, left as an open question rather than designed here. - No filter yet. A
basisinput is a tool change under hard rule 8, with no evidence yet that an agent needs it. Revisit when a transcript or an eval shows one. - Optional companion: return
memoryId, which the row and the export already carry (packages/db/src/data-export.ts:387), so a reader can open the memory behind a fact. searchandbriefing: no change. They return memories, never fact rows. Carrying basis there needs a join they do not make, or a memory-level field, which waits on the memory types question.
get_facts.
4. Calibration before agents rely on confidence
Two sources writeconfidence, and they differ:
- Writer-declared (
remember, import): any client model or a person, with no defined meaning and no ground truth the engine can check. The engine cannot measure its calibration. - Model-produced (the #228 extractor’s score): one pinned model on a known task, which a public fixture can test, as the forgetting pass does.
- Give the
confidenceinput a description: the writer’s probability that the value is correct. A field description regenerates the reference snapshot but not the tool-selection embeddings, which embed tool descriptions only (eval/src/tool-selection.mjs:292). - Document writer-declared confidence as self-reported and uncalibrated.
- Before the extractor applies a threshold, report its ECE on a public synthetic fixture with the forgetting harness’s binning, report-only as there. Review decisions add a second, biased check: applied and rejected fact proposals keep their confidence, but acceptance is not correctness, and if the extractor stores only proposals above its threshold, as issue #228 plans, the low bins stay empty.
- No engine behaviour (supersede, ranking, filtering) depends on confidence until a calibration report exists for its source.
Out of scope
- Retro-labelling existing facts without the owner’s review. Unknown stays unknown.
- Any change to the memory types enum before this design is accepted.
- Validation outside
packages/schema(hard rule 2), and any write that is not append-and-supersede (hard rule 1).
Decisions for the owner
- Basis: store a writer-declared
basisper fact (recommended), derive it from the writer, or store both a basis and a write path? Does an accepted proposal recordstated(recommended), orinferredplus a reviewed marker? - Values: are
stated,observedandinferredthe right set, and dostatedandobservedshare the grounded tier? - Supersede: does an inferred direct write that meets an open grounded value become a fact proposal (recommended), or does the rule stay basis-blind? Either way, the gate binds only writers that declare
inferredor, under U2, omit basis. - Unknown basis: grounded everywhere (U1), grounded only for rows whose
created_atpredates thebasisrelease (U2, recommended), or inferred everywhere (U3)? U2 stages every current client’s update to a pre-cutoff key for review until that client sendsbasis, so it also needs a decision on when the rule turns on. - Sequencing: does the supersede change wait for the #369 production evaluation (recommended)?
- Reads: label only on
get_facts(recommended)? Shouldget_factsalso returnmemoryId? Formanykeys, keep the shipped order (recommended) or put grounded values first, and isget_factspagination worth designing (open)? - Calibration: define
confidence, label writer-declared values as uncalibrated, and require an ECE report before the extractor or any rule relies on it (recommended)?