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 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.