Two scope fences with different rules

GoodMemory

A TypeScript memory layer for chat apps and installed coding agents whose writeback candidates are content-hashed, held for an operator to approve, and refused for good once forgotten — while the recall path matches workspace and tenant exactly and the export path matches them as a subset, so a scope that returns nothing on one returns everything on the other.

Carries 6 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

GoodMemory is a memory layer for chat apps and for coding agents installed in a host — Codex and Claude Code — published under MIT at version 0.8.0, with 1,131 commits since 23 March 2026 across 163,939 lines of TypeScript in src/ and 392,190 lines across 807 test files. Its README is explicit about what it is not: "not an LLM, agent framework, vector database, or generic RAG system", but "the product memory layer between your app or installed agent host and the model runtime".

Three mechanisms here are better than the category norm.

The first is a tombstone keyed on content. A writeback candidate's key is sha256 over the user, workspace, agent, kind and lower-cased content, so it identifies the statement rather than a row. When a person marks a written memory a false write, the ledger records forgotten and — this is the part that matters — keeps the key in ledger.events. Every later propose checks that set first and returns the ledger untouched. The same sentence extracted again from a later session never comes back.

The second is a review mode that holds candidates outside memory. In review mode the installed host extracts candidates and writes none of them; they sit in a queue for an operator to approve or reject in the Inspector. Approval is reserved before the durable write so two concurrent approvals cannot both commit, a failed write releases the reservation, and a stale interrupted approval requires operator recovery rather than retrying itself.

The third is evidence discipline about its own numbers. Every figure in the README's public claims table is backed by a committed declaration in benchmark-claims/ recording the exact command, commit, package version, judge, dataset source and licence, and a gate refuses a README number whose declaration does not pass. The declarations distinguish a strict deterministic track from a comparable track that re-judges the same stored answers under the benchmark's official protocol, and they carry their own caveats — the LoCoMo file records a "heuristic provider-variance estimate" and warns that external headline scores "are references, not controlled head-to-head measurements". That is the traceability habit this atlas finds missing far more often than it finds it.

What does not hold together as well is scope. There are two fences and they do not agree. On the recall path, filterRecordsByDefaultRecallScope requires record[key] === scope[key] for tenant and workspace, so a scope carrying only a userId matches only records that have no tenant and no workspace. On the admin and export path, buildScopeFilter drops undefined fields before querying, so the same bare scope matches every tenant and every workspace that user has. exportMemory is a public API method. The result is that the scope GoodMemory's own isolation scenario is built to protect — workspace — is enforced exactly on one read path and widened by omission on another.

Six marks: tombstone, human_review, trust_state, scope_enforced, bitemporal, negative_eval.

2. Mental Model

A scope is five fields: userId (required, non-empty, or normalizeScope throws), tenantId, workspaceId, agentId, sessionId. scopeToKey joins them with :: into the key stored on every record.

A record is one of six typed shapes. Beyond its content it carries a confidence, an evidenceCount, a MemorySource (explicit, inferred, import or confirmed, with the instant it was extracted), a lifecycle, and optionally a validity interval and a TTL.

A lifecycle is active, superseded or inactive, and the transitions are a declared table: active may go anywhere, superseded may only go to inactive, inactive may return to active. transitionLifecycle throws on anything else.

A claim is a projection of a record into a subject-predicate-object slot with its own validity interval. Claims are append-only: queryClaims returns the current value per slot, queryClaimHistory returns everything.

A candidate is a proposed writeback from an installed host, identified by the hash of its own content, and living in one of two files under the install root — the review queue if the host is in review mode, the audit ledger once it has been staged.

Diagram — the same candidate content is refused forever once forgotten; recall matches tenant and workspace exactly while export matches whichever fields the caller supplied
Diagram source
%% caption: the same candidate content is refused forever once forgotten; recall matches tenant and workspace exactly while export matches whichever fields the caller supplied
flowchart TB
    HOST["installed host turn<br/>Codex / Claude Code"] --> EXTRACT["extract candidate"]
    EXTRACT --> KEY["buildCandidateKey =<br/>sha256(user, workspace, agent,<br/>kind, content.toLowerCase())"]
    KEY --> SEEN{"key in ledger.events,<br/>or status committed /<br/>dismissed / forgotten?"}
    SEEN -->|"yes"| DROP["return ledger unchanged<br/>never re-staged"]
    SEEN -->|"no"| MODE{"writeback mode"}
    MODE -->|"review"| QUEUE[("review queue<br/>status pending<br/>NOT durable memory")]
    QUEUE --> OP{"operator in Inspector"}
    OP -->|"reject"| REJ["status rejected<br/>no memory written"]
    OP -->|"approve"| RESERVE["reserve approval"]
    RESERVE --> REM["durable remember"]
    MODE -->|"selective"| REM
    REM --> REC[("record<br/>scopeKey, lifecycle,<br/>validFrom / validUntil,<br/>confidence, evidenceCount")]
    FALSE["operator marks false_write"] --> FORGET["forget linked records"]
    FORGET --> TOMB["status forgotten<br/>dedupe key PRESERVED"]
    TOMB --> SEEN
    REC --> PROJ{"lifecycle active?"}
    PROJ -->|"no"| NOIDX["nothing projected<br/>into the recall index"]
    PROJ -->|"yes"| IDX[("recall index + claims")]
    IDX --> RECALL["recall pipeline"]
    RECALL --> GUARD["exact match:<br/>record.tenantId === scope.tenantId<br/>record.workspaceId === scope.workspaceId"]
    REC --> EXPORT["exportMemory / admin"]
    EXPORT --> SUBSET["buildScopeFilter drops<br/>undefined fields<br/>bare scope matches ALL workspaces"]

3. Architecture

Area Role
src/domain The record, scope, provenance, temporal and taxonomy types — the smallest place to read the model
src/storage One document-store port with SQLite, Postgres and in-memory implementations; repositories.ts holds listByScope and buildScopeFilter
src/remember Extraction, rules, profiles and the write handlers
src/recall The retrieval pipeline: routing, BM25, fusion, decomposition, iterative recall, reranking, selection, context assembly, the evidence ledger, and projections/ for claims
src/install Installed-host integration: the writeback runtime, the audit ledger, the review queue, host config and hooks
src/api createGoodMemory, the public operations, admin and governance ops
src/governance, src/policy, src/verify Write policy hooks, page artifacts, verification
apps/inspector-web The local React Inspector where an operator reviews candidates
benchmark-claims, REPRODUCING.md, scripts The committed claim declarations and the gate that enforces them

4. Essential Implementation Paths

  • src/domain/scope.ts:18-42normalizeScope and scopeToKey.
  • src/recall/policy.ts:15-27 — the exact-match recall scope guard.
  • src/storage/repositories.ts:279-288buildScopeFilter, the subset filter.
  • src/install/hostWritebackRuntime.ts:1816-1833buildCandidateKey.
  • src/install/hostWritebackAuditLedger.ts:144-156 — the refusal on a known key.
  • src/install/hostReviewQueue.ts:11-49 — the review queue's contract.
  • src/recall/generalizedSelection.ts:35-48isFactVisibleAt.
  • src/domain/provenance.ts:23-40 — the lifecycle transition table.

5. Memory Data Model

Six record types share a spine: scope fields, confidence, evidenceCount, source, lifecycle, supersededBy, updatedAt. Facts and claims add validity: validFrom, validUntil, expiresAt. MemorySource.method separates what the user said (explicit), what the system inferred (inferred), what arrived through import, and what a person confirmed — a four-way distinction most stores in this corpus collapse into one boolean.

Claim projections are the interesting layer. A claim is a slot value with a validity interval, a polarity and a modality, and the projection runtime keeps every version: a replacement closes the earlier value's interval when it becomes valid, and a value ingested late but valid earlier is bounded on arrival rather than treated as current. queryClaims exposes the head; queryClaimHistory exposes the lot.

6. Retrieval Mechanics

Recall is a pipeline, not a query. A router picks a retrieval profile, lexical and vector lanes fuse, a decomposer splits multi-part questions, iterative recall re-queries, a reranker reorders, and selection fills a token budget. Two filters run before any of it: the exact-match scope guard, and the lifecycle-and-validity gate in isFactVisibleAt, which drops anything not active, anything expired, and anything whose validFrom is after the caller's reference instant.

The reference instant is a caller argument throughout, not Date.now() buried in a helper, which is what makes the temporal tests possible to write.

7. Write Mechanics

remember runs configurable extractors and rules. For installed hosts the writeback runtime has four modes — off, observe, review, selective — and only the last two can produce durable memory without a person. The ledger is written before the durable write and updated after, so a crash between them leaves a pending event rather than an untracked write, and the recovery path requires an operator rather than retrying on its own.

Everything stored from a transcript goes through containsSensitiveCredential and a bounded preview; the review queue's own comment says stored content is "the bounded, secret-redacted candidate statement — never the raw transcript".

8. Agent Integration

goodmemory setup installs hooks for Codex and Claude Code, goodmemory status reports what is wired, and the MCP server is read-only unless writeback is opted into. Package exports cover the library, an AI SDK adapter, a host adapter and an HTTP surface. The Inspector is a local web app, which is where review actually happens.

9. Reliability, Safety, and Trust

The two scope fences do not implement the same rule. This is the finding. filterRecordsByDefaultRecallScope compares tenant and workspace with ===, which means an undefined field on the scope matches only an undefined field on the record. buildScopeFilter builds its query from Object.entries({...}).filter((entry) => entry[1] !== undefined), which means an undefined field is simply not asked about. Both are reasonable in isolation; together they mean a caller holding a user-only scope sees almost nothing through recall and everything through exportMemory. Nothing here crosses a user boundary — userId is required and always compared — so this is a widening within one user's memory, not a leak between people. But workspace separation is the thing the project's own scenario test exists to prove, and the export path does not hold it.

The lifecycle filter is spread across the pipeline, not held at the store. listByUser returns retired rows untouched, and a committed test asserts exactly that: a fact demoted with demotionReason: "ttl_expired" still comes back from the repository. The filtering happens in isFactVisibleAt, at four seams in contextBuilder, in generalizedSelection, and at projection time. That is defensible — the projection gate means a superseded record never enters the recall index at all — but it is a rule enforced in many places rather than one, and a new read surface inherits none of it by default. listByUser has no caller in src, apps or clients at this pin; it is reachable only through the repository port.

The tombstone and the audit ledger are scoped to installed-host writeback. Both files live under the install root and both are keyed to host candidates. A remember call made directly through the library has no equivalent refusal list and no equivalent ledger, so audit_log is withheld: the record does not cover all write paths. The claim projection's append-only history is the closest thing on the library side, and it records claim revisions rather than mutations.

10. Tests, Evals, and Benchmarks

807 test files, more lines of test than of source. The ones that carry the marks are named in the frontmatter. Three habits are worth copying.

The scenario fixtures in tests/scenarios/behavior-fixtures.ts express an expectation as hasEntries beside lacksEntries on the same assembled block, so a positive control and a must-not live in one declaration and cannot drift apart.

tests/unit/inspector-candidate-review.test.ts tests the failure modes of approval — a concurrent replay, a durable write that fails, a stale interrupted approval, a candidate from another scope — rather than only the happy path.

And the benchmark claim gate is a test over the README: it cross-checks the public tables in both languages against the committed declarations, so a number cannot be edited into the prose without its evidence.

11. For Your Own Build

Steal

  • Key the tombstone on the content, not the row. Hashing scope, kind and normalised content means a re-extraction of the same sentence collides with the thing the user already rejected. A row id would not.
  • Preserve the dedupe key through the forget. The subtle half is that deleting the memory must not delete the memory of having rejected it; the test that pins this is named for exactly that property.
  • A review mode that writes nothing. Candidates that live outside memory until a person acts are a different guarantee from candidates written and later approved, and the difference shows up the moment review is abandoned.
  • Reserve before the durable write. Approval reservation plus release on failure plus operator recovery on a stale reservation is the full set, and most review queues in this corpus implement none of the three.
  • A gate that refuses an undeclared number. benchmark-claims/ with a strict track and a re-judged comparable track is the strongest answer to benchmark traceability this atlas has read.

Avoid

  • Two scope filters with different semantics on the same record shape. If one path treats an absent field as "must be absent" and another treats it as "don't care", the difference will be discovered by whoever calls the second one with a scope built for the first.

Fit

Reach for this if you are adding memory to an installed coding agent and want the write path gated on a person, with rejections that stick. Look elsewhere if you need one uniform scope rule across every read surface, or a mutation audit covering library writes and not only host writeback.

12. Open Questions

  • Should buildScopeFilter adopt the recall guard's exact-match semantics, or should exportMemory require the caller to state the scope fields it intends to cross? Either resolves the asymmetry; the current pair does not.
  • Is listByUser still needed? It has no caller outside tests and is the one repository read with no scope beyond the user.
  • The tombstone is per installed host, under the install root. What happens to a rejection when the same user works from a second machine?

Appendix: File Index

Path What to read it for
src/domain/scope.ts The scope model and its canonical key
src/domain/provenance.ts The lifecycle states and the transition table
src/recall/policy.ts The exact-match recall scope guard
src/storage/repositories.ts listByScope, listByUser and buildScopeFilter
src/install/hostWritebackAuditLedger.ts The tombstone and the writeback audit
src/install/hostReviewQueue.ts The review queue's contract, in its own words
src/recall/generalizedSelection.ts Lifecycle and validity at read time
src/recall/projections/ Claim projection, history and revision visibility
REPRODUCING.md, benchmark-claims/ The claim declarations and the gate
tests/scenarios/behavior-fixtures.ts hasEntries beside lacksEntries

History

2026-09-16416c15b4… — first reading, at a commit dated 14 September 2026. Screened before opening, from a shallow clone: thirty-six files, one auto-run surface, one build-time execution point, eleven unpinned surfaces, seventeen dependency files inside the cooldown, and AGENTS.md and CLAUDE.md read as data. Nothing was installed, built or run.