The migration that added the second time axis says what question the first one could not answer

bwmem

A per-user memory SDK on Postgres whose facts carry both time axes, whose four-value status keeps three of them out of every read, and whose corrections table records the old value, the new one and why.

Carries 4 of 7 rubric mechanisms. Most systems here carry none or one (43%), 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

The source of this report no longer exists upstream. Bitwarelabscom/bwmem returns 404 with no rename redirect, and the account's only remaining public repository is its profile. The atlas holds a copy: the fork at agent-memory-atlas-archive/Bitwarelabscom--bwmem carries the pinned commit and the upstream history through 21 September 2026, so every file path below is readable there. Links in the body point at the dead upstream because that is where the reading happened; the archive is the working route. chispaumana/bwmem is an unrelated copy whose only branch stops on 15 August 2026, six commits before the pin.

bwmem is a "Memory SDK for AI chatbots" giving "your bot persistent, per-user memory: bi-temporal facts, semantic search, emotional capture, contradiction detection, quality scoring, session-texture carryover, held intentions, knowledge graph, and multi-stage consolidation." AGPL-3.0, TypeScript, version 0.11.2, 17,259 lines across 105 files, on PostgreSQL with pgvector, Redis, and optionally Neo4j. It is extracted from Luna, the vendor's longer-running agent system.

The document worth reading is a migration. 007_bi_temporal_facts.sql opens by stating what the schema could not do before it:

"The facts table already has valid_from / valid_until (when a fact was true in the world) and supersedes_id (lineage). What was missing is the second time axis — WHEN WE CHANGED OUR BELIEF — distinct from when something was true. Without this you can't honestly answer 'what did we believe about X on date Y' — you can only answer 'what was true on date Y.'"

It then defines each column in a single line, writes out the composed query, and getFactsAsOf(userId, asOfValidTime, asOfTxnTime) implements it with both instants defaulting to now. That default is the part that matters: the ordinary read and the historical read are the same function with different arguments, so the historical path cannot quietly break while the current one keeps working — the failure the atlas found two reports into this same family.

The status vocabulary is enforced by the database. fact_status is constrained to active, overridden, superseded and expired, and the partial index is built WHERE fact_status = 'active' — so the active set is the read set in the planner as well as in the predicate. A superseded fact keeps its row, its lineage and its place in the bitemporal history while leaving what the bot is handed.

Seven of the eight reads of the facts table carry the predicate — retrieval (facts.service.ts:306), full-text search (:397), the mention-count lookup (:543), the relevance pass (:648), the similarity check (:689), quality scoring (quality-scorer.service.ts:402), collision detection (fact-collision.service.ts:421) and the contradiction correlation (contradiction.service.ts:230). The eighth leaves it out on purpose, and the omission is the interesting part: getFactsAsOf (:441-458) answers what was believed at one time about the world at another, and composes recorded_at, superseded_at, valid_from and valid_until instead. Filtering that query to active would answer the question wrong — a fact superseded last week was the belief the week before, which is exactly what the caller asked for. This atlas more often finds the opposite: a predicate that holds on the main read and silently lapses on a second one. Here the one read without it is the one that should not have it.

Scope is the other thing done without a gap. user_id is stored on every fact, is the first bound parameter of every read, and is a required argument with no unscoped variant; the indexes are keyed on it. Migration 007 adds intent_id beside it for facts that belong to one conversation thread "and should not bleed into the user's general facts."

The trade to notice is in the corrections log. fact_corrections records old_value and new_value in the clear, which is exactly what makes "how did we come to believe what we believe" answerable — and exactly what Verimem, read a report earlier, argues an immutable log must never contain, because it turns erasure into a contradiction. Both positions are defensible; what bwmem does not have is a purge path that reaches both tables.

2. Mental Model

A fact is per-user, keyed, and never deleted.

A status is one of four, and only one of them is readable.

A correction writes a row saying what the value was, what it became, and why.

An as-of read is the ordinary read with two instants supplied.

Diagram — facts carry validity bounds and transaction bounds on the same row, every read is scoped to a user and filtered to the active status, and a supersession writes both a status change and a corrections row in one transaction
Diagram source
%% caption: facts carry validity bounds and transaction bounds on the same row, every read is scoped to a user and filtered to the active status, and a supersession writes both a status change and a corrections row in one transaction
flowchart TB
    MSG["a message"] --> EX["background extraction:<br/>fact key, value, category, type,<br/>valid_from / valid_until"]
    EX --> NEW[("facts: user_id · intent_id · fact_key · fact_value<br/>valid_from · valid_until — when it was TRUE<br/>recorded_at · superseded_at — when we BELIEVED it<br/>fact_status · supersedes_id · override_priority")]
    CHK["CHECK (fact_status IN ('active','overridden','superseded','expired'))"] --> NEW
    CONTRA["contradiction detection over<br/>ACTIVE values for the same key,<br/>correlated on f.user_id = c.user_id"] --> SUP
    SUP{"a correction"} --> S1["UPDATE facts SET fact_status = ...,<br/>superseded_at = NOW()"]
    SUP --> S2["INSERT INTO fact_corrections<br/>user_id · fact_key · old_value · new_value ·<br/>correction_type · reason"]
    S1 & S2 -.->|"one client transaction, and there is no<br/>DELETE FROM facts anywhere in the tree"| KEPT["the row keeps its lineage<br/>and leaves the read set"]
    S2 -.->|"old and new values in the clear —<br/>what makes the lineage answerable, and what<br/>makes an erasure reach two tables"| TRADE["the opposite trade from a hash-only chain"]
    NEW --> READ["every read: WHERE user_id = $1<br/>AND fact_status = 'active'"]
    READ -.->|"userId is a required first argument on every<br/>public read; no unscoped variant exists, and the<br/>indexes are keyed (user_id, …)"| SCOPED["the scope is the plan, not a post-filter"]
    READ --> TEMP["AND NOT (fact_type = 'temporary'<br/>AND valid_until <= NOW())"]
    TEMP -.->|"a temporary fact past its window is withheld<br/>before any sweep marks it expired"| SAFE["expiry does not wait for a job"]
    ASOF["getFactsAsOf(userId, asOfValidTime, asOfTxnTime)<br/>both default to now"] --> P1["recorded_at at or before txn instant,<br/>and superseded_at null or after it"]
    ASOF --> P2["valid_from null or at or before valid instant,<br/>and valid_until null or after it"]
    P1 & P2 -.->|"'what we believed at txn_time<br/>about state at valid_time'"| ANS["the ordinary read IS the historical read,<br/>so the historical path cannot rot alone"]

3. Architecture

Area Role
src/db/migrations/ The schema, with the reasoning in the migration headers
src/memory/facts.service.ts Writes, supersession, the corrections row, the as-of read
src/memory/contradiction.service.ts Held contradictions over active values for one user
src/memory/quality-scorer.service.ts Response scoring against what the store holds
src/consolidation/ The multi-stage background passes
src/graph/ Optional Neo4j sync, non-blocking and counted on failure

4. Essential Implementation Paths

src/db/migrations/007_bi_temporal_facts.sql:1-27 — the problem, the four columns, and the composed query, before any DDL.

src/memory/facts.service.ts:437-458 — both axes as parameters, each predicate commented with the question it answers.

src/db/migrations/001_core.sql:49-50, :65 — a status constrained by the database and indexed on its own predicate.

src/memory/facts.service.ts:590-621 — a supersession and its audit row in one transaction.

5. Memory Data Model

A fact carries a user, an optional intent, a key and value, a category and type, a confidence, an override priority, both validity bounds, both transaction bounds, a status and a lineage link. Messages, sessions, held intentions and emotional capture sit beside it, and fact_corrections records every belief change.

6. Retrieval Mechanics

Full-text search with to_tsquery and semantic search over pgvector, both scoped to the user and filtered to active facts, with a temporary-fact expiry clause applied in the same predicate rather than depending on a sweep. A context builder assembles what reaches the prompt.

7. Write Mechanics

Messages are recorded, facts extracted in the background, and a conflicting value supersedes rather than overwrites: status and superseded_at on the old row, a new row with supersedes_id, and a corrections row naming both values. Graph sync is fire-and-forget with a counter on failure, so the optional service cannot block a write.

8. Agent Integration

An SDK rather than a server: record messages, build context, inject into the prompt. A Docker compose file brings up Postgres, Redis and Neo4j for local use.

9. Reliability, Safety, and Trust

The strong parts are the two axes with one read path, a status the database constrains, and a user scope no read omits. The gaps are a corrections log that stores plaintext values with no purge that reaches it, no value-keyed record to stop a corrected fact being re-extracted, no person anywhere in the loop, and tests that stop at the unit level.

10. Tests, Evals, and Benchmarks

Vitest unit tests over SQL shape, context formatting, consolidation gating, contradiction handling and embedding behaviour. There is no store-level case asserting that a superseded fact or another user's fact fails to come back, which for a design that rests on exactly those two predicates would be the cheapest assertion to add.

11. For Your Own Build

Write the migration header the way this one is written. Stating the question the old schema could not answer, before the DDL, is what makes a second time axis a decision rather than two more columns.

Default both as-of parameters to now. If the historical read is a separate method, it will break separately and later.

Constrain the status in the database and index on the predicate you filter with. It makes the active set the read set in two places instead of one.

Decide whether your audit log may hold values, and know what you are trading. In the clear it answers how a belief formed; hashed or omitted it keeps erasure possible. Either is defensible; having neither answer is not.

12. Open Questions

Whether an erasure path is planned that reaches fact_corrections. The facts table is append-only by design and the corrections table holds the same values, so a user's request to be forgotten currently has two places to go and one mechanism.

Whether the reason column is meant to carry an explanation. It is populated with one of two fixed strings today, and a column named reason in an audit log invites a reader to expect the sentence somebody wrote.

Appendix: File Index

Path What to read it for
src/db/migrations/007_bi_temporal_facts.sql:1-27 The clearest statement of why one time axis is not enough
src/memory/facts.service.ts:437-458 One read path serving both the current and the historical question
src/db/migrations/001_core.sql:49-50, :65 A status the database enforces and the index agrees with
src/memory/facts.service.ts:590-621 A supersession and its audit row, in one transaction

History

2026-10-01 — the upstream repository stopped resolving. Bitwarelabscom/bwmem returns 404 to an authenticated request with no rename redirect, and the archive fork's parent link was dropped by GitHub. The pin is not moved: 1d0acee2… is in the fork, and section 1 says where to read it. Nothing else in the report changed.

2026-09-19 — 1d0acee2… — trust_state re-tested against the narrowed line, and the record's universal claim was worth checking: it said every read path filters to fact_status = 'active'. Seven of the eight reads of the facts table do, and the eighth leaves it out deliberately — getFactsAsOf (src/memory/facts.service.ts:441-458) answers what was believed at one time about the world at another and composes recorded_at, superseded_at and the validity window instead, because filtering to active would answer that question wrong: a fact superseded last week was the belief the week before. The mark holds and is better stated with the exception in it. Every other anchor re-checked in source, including the CHECK constraint and the partial index. The evidence record now names the eight sites and carries a test citation, tests/unit/temporary-fact-expiry.test.ts:80, which asserts the expiry sweep only ever touches temporary, active rows — the field previously held prose rather than a test. Screened again first; nothing was installed and no suite was run.

2026-09-16 — 1d0acee2… — first reading, at a commit dated 12 September 2026. Screened before opening, from a shallow clone: no auto-run surfaces, one build-time execution point, one unpinned dependency surface and two dependency files inside the seven-day cooldown, with package-lock.json present. Nothing was installed, built or run; no PostgreSQL, Redis or Neo4j was started, so the SQL described here is read from the migrations and the service source rather than executed.