Two lifecycle columns whose reconciler is not the migration's inverse

BitterBot Desktop

A local-first personal agent with 127,873 lines of biologically-framed memory — synaptic tagging, reconsolidation, spacing, a dream engine — whose knowledge graph does genuine as-of belief revision, and whose chunk store carries two lifecycle columns reconciled in one place by a function that maps expired to archived while the migration maps forgotten to expired.

Carries 3 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

BitterBot Desktop is a local-first personal agent — MIT, Node ≥ 22, version 2026.2.15, 740,633 lines of TypeScript across 1,382 test files, of which 127,873 lines and 156 test files are memory. It pitches "biological memory, a dream engine, and a P2P skills economy," and unlike most projects that reach for that vocabulary, the biology is implemented rather than gestured at: synaptic-tagging.ts, reconsolidation.ts, spacing-effect.ts, somatic-markers.ts, hormonal.ts, mood-congruent-boost.ts, each with a citation and a mechanism.

Several of those files announce themselves as the "FIRST IMPLEMENTATION in any agent memory system." That claim is not assessed here; the mechanisms are.

Two things in it are genuinely well built.

The first is the belief layer on the knowledge graph. Relationships carry valid_from and valid_until — a valid-time interval distinct from the created_at the rows are ordered by — and supersession closes the interval rather than deleting the edge. beliefHistory(entityId, { validAt }) deliberately drops the active-only guard that every other read applies, with the comment explaining why: so callers can "answer 'what did I believe about X as of T?'". The test asserts both halves — a closed edge is absent from ordinary traversal and present in the history — which is what makes it an eval rather than a demo.

The second is the canonical facts ledger, and its reasoning is worth quoting because it identifies a failure mode most retrieval-gated stores have and nobody names: "importance is orthogonal to similarity: a canonical fact is short, low-entropy, and shares no embedding mass with a cold conversation's first message." So the ledger is addressed by key, injected unconditionally, hard-capped, and demoted by a deterministic score — "never an LLM prose decision."

The finding is in the other column.

chunks carries two lifecycle fields, lifecycle_state and the newer lifecycle. The code knows this: chunk-writer.ts:44 heads the section "the tangled cluster the audit's C1 bug lived in", and setChunkLifecycle is documented as "the single place that reconciles the two columns (audit C1)." The write side is genuinely fixed.

The read side is not. skill-version-resolver.ts filters both of its version queries with AND lifecycle_state != 'expired' — and expired is not a value lifecycle_state ever holds. deriveLifecycleState maps the expired lifecycle to the archived state, so an expired skill has lifecycle_state = 'archived', which satisfies != 'expired' and is returned as a live version. Worse, SQL's three-valued logic means a row whose lifecycle_state is NULL — the column was added by migration — fails the predicate and is excluded. The filter admits exactly what it means to reject and rejects rows it has no opinion about.

And the two mappings are not inverses. The migration maps lifecycle_state = 'forgotten' to lifecycle = 'expired'; deriveLifecycleState maps expired back to archived. A forgotten chunk that round-trips becomes an archived one.

2. Mental Model

A chunk is the base unit: text, embedding, importance, a lifecycle in two columns, hormonal and somatic scalars, a provenance chain.

A canonical fact sits above the chunk store, addressed by key rather than retrieved, injected into every system prompt, hard-capped.

An entity and a relationship form the graph. A relationship has a validity interval; superseding one closes the interval.

A dream is a background cycle that consolidates, distils skills, and grades itself on whether its output later gets used.

Diagram — the belief layer closes intervals and offers a real as-of read, while the chunk store's two lifecycle columns are reconciled on write by a function that is not the migration's inverse — and one read filter tests a value its column never holds
Diagram source
%% caption: the belief layer closes intervals and offers a real as-of read, while the chunk store's two lifecycle columns are reconciled on write by a function that is not the migration's inverse — and one read filter tests a value its column never holds
flowchart TB
    subgraph KG["knowledge graph — the part that works"]
        E["entity"] --> R["relationship<br/>valid_from / valid_until"]
        SUP["supersede"] --> CLOSE["set valid_until —<br/>the edge is NOT deleted"]
        CLOSE --> R
        R --> TRAV["traverseEntity:<br/>valid_until IS NULL"]
        R --> BH["beliefHistory(validAt):<br/>includeClosed = true —<br/>'the universal guard is<br/>deliberately dropped here'"]
        BH --> ASOF["beliefAsOf(ts):<br/>'what did I believe<br/>about X as of T?'"]
        TEST["test asserts BOTH:<br/>traverseEntity → 0,<br/>beliefHistory → ≥1 with<br/>validUntil != null"] -.-> BH
    end
    subgraph CH["chunk store — the tangle"]
        SET["setChunkLifecycle —<br/>'the single place that reconciles<br/>the two columns (audit C1)'"]
        SET --> D["deriveLifecycleState"]
        D -->|"expired → archived"| LS[("lifecycle_state<br/>active | archived |<br/>consolidated | forgotten")]
        SET --> LC[("lifecycle<br/>generated | activated | frozen |<br/>consolidated | archived | expired")]
        MIG["migration CASE"] -->|"forgotten → expired"| LC
        MIG -.->|"handles 'consolidating',<br/>which chunk-writer never writes;<br/>'consolidated' falls to ELSE"| GEN["→ 'generated'"]
        LS --> RET["retrieval:<br/>lifecycle_state <> 'forgotten'<br/>AND lifecycle <> 'expired'"]
        LC --> RET
        LS --> BAD{"skill-version-resolver:<br/>lifecycle_state != 'expired'"}
        BAD -->|"expired skill has<br/>lifecycle_state = 'archived'"| INC["INCLUDED —<br/>the filter never fires"]
        BAD -->|"NULL != 'expired' is NULL"| EXC["EXCLUDED —<br/>rows with no state"]
    end
    CF["canonical facts —<br/>addressed by key, injected<br/>unconditionally, hard-capped<br/>'importance is orthogonal<br/>to similarity'"] --> PROMPT["prompt"]
    RET --> PROMPT

3. Architecture

Area Role
src/memory/chunk-writer.ts The lifecycle types, the reconciler, access bumping
src/memory/memory-schema.ts Tables, indexes, the audit log
src/memory/migrations.ts Column additions and the lifecycle CASE
src/memory/canonical-facts.ts The key-addressed ledger and its closed op set
src/memory/conflict-resolver.ts Supersede-by-similarity before storing
src/memory/knowledge-graph.ts Entities, relationships, belief history
src/memory/temporal-filter.ts Both bitemporal vocabularies, and the bridge between them
src/memory/consolidation.ts Forgetting, merging, and the purge sweep
src/memory/dream-*.ts The dream engine, its oscillator, and its self-evaluator
src/memory/synaptic-tagging.ts, reconsolidation.ts, spacing-effect.ts, hormonal.ts, somatic-markers.ts The biological strengthening machinery

4. Essential Implementation Paths

chunk-writer.ts:44-108, then migrations.ts:56-76, then skill-version-resolver.ts:250-260. Read them in that order and the whole finding assembles itself: the types, the forward mapping, the reverse mapping, and the filter that uses a value from the wrong vocabulary.

knowledge-graph.ts:495-567 for the belief layer, which is the part to copy.

canonical-facts.ts:1-25 for the clearest statement in this corpus of why a purely similarity-gated store loses its most important facts.

5. Memory Data Model

The chunks table starts small — id, path, source, line span, hash, model, text, embedding, updated_at — and grows by migration into something much wider: importance, lifecycle, lifecycle_state, parent_id, version, hygiene_done, memory_type, semantic_type, hormonal scalars, governance_json, provenance_chain, created_at, last_consolidated_at, valid_time_start, valid_time_end, transaction_time, stable_skill_id, skill_version, deprecated, lineage_hash, peer_origin.

That accretion is where the report's finding lives. Two of those columns encode the same concept with different vocabularies, and the project knows it — the reconciler exists precisely because of an audit finding labelled C1. What the reconciler cannot fix is a query written against the wrong vocabulary, because nothing type-checks a SQL string literal.

The two exported LifecycleState types make this concrete. chunk-writer.ts:46 declares "active" | "archived" | "consolidated" | "forgotten". memcube.ts:10 declares "active" | "consolidating" | "archived" | "forgotten". Same name, different fourth member. The migration's CASE has a branch for lifecycle_state = 'consolidating' — the memcube spelling — and none for consolidated, so a chunk the writer marked consolidated falls through to ELSE 'generated' and is migrated as though it were freshly generated, losing the fact that it had already been consolidated.

Canonical facts are a separate, much tidier table: key, value, statement, category, confidence, first_seen_at, last_confirmed_at, valid_from, valid_until, source, evidence, status. canonical_conflicts holds one unconsumed row per key recording a rejected proposal — its own comment explains the cap: "a dream-cycle promotion retrying the same rejected pin every cycle must not grow the table without bound."

That conflict row is close to a tombstone and is not one. It is keyed on the fact's key, deduplicated on the key rather than the value, and exists to be "swept into a user-facing question" — nothing on the write path consults it to refuse a re-proposal, so the same rejected value can be re-asserted once the row is consumed.

6. Retrieval Mechanics

Hybrid vector and lexical retrieval fused by reciprocal rank, with graph expansion, a query planner, recency and mood-congruent boosts, and a retrieval trace for diagnosis. The retrieval filter is written correctly, checking both columns and both NULL cases:

AND (lifecycle_state IS NULL OR lifecycle_state <> 'forgotten')
AND (lifecycle IS NULL OR lifecycle <> 'expired')

That is the right pattern, and it is what makes the skill resolver's version of the same idea stand out as a slip rather than a misunderstanding.

The chunk-vocabulary bitemporal helpers are the other loose end. buildTemporalWhereClause and currentFactsOnly are exported, documented and tested, and nothing in src calls them — conflict-resolver.ts:57 writes its own valid_time_end IS NULL inline instead. So chunks are maintained bitemporally on write and there is no chunk read that takes an as-of. The helper also has a trap for whoever wires it up first: validAt does not imply excludeSuperseded: false, so buildTemporalWhereClause({ validAt: t }) emits valid_time_end IS NULL alongside the interval conditions and silently degrades a point-in-time query to a current-facts query.

The relationship variant has none of these problems, and its doc comment is explicit about why it exists rather than parameterising the column names: "buildTemporalWhereClause above is hardcoded to the chunks vocabulary and its callers depend on that, so we do NOT parameterize column names there. This sibling is the single explicit bridge for the relationships vocabulary."

7. Write Mechanics

The conflict resolver runs before a fact is stored: cosine above 0.95 is a no-op, above 0.85 supersedes the old chunk by setting valid_time_end, below that is a new fact. No extra LLM call, and the old row survives.

The canonical ledger's op set is closed — ADD, STRENGTHEN, SUPERSEDE, REJECT — and its invalid-input path is documented as "REJECTED (never a silent partial write)". When the cap is reached the demotion is score-based, and when it cannot make room it retires nothing and rejects the ADD, atomically, with a comment noting that "a rejected write never mutates the ledger".

Consolidation marks chunks forgotten and reparents merged ones, logging an audit row; a later sweep hard-deletes from chunks_vec, chunks_fts and chunks where (lifecycle_state = 'forgotten' OR lifecycle = 'expired') and the row is older than a threshold. That deletion predicate checks both vocabularies correctly.

memory_audit_log is written by consolidation.ts, coverage-diagnostics.ts, epistemic-directives.ts, governance.ts and manager.ts. It is not written by chunk-writer.ts — the module that owns the single reconciler for lifecycle transitions — so whether a state change is audited depends on which caller made it. That is the inverse of the pattern worth having: put the record where the mutation is, not in each caller.

8. Agent Integration

One gateway process on one port serves the Control UI, the agent, and the P2P orchestrator. Channels include WhatsApp; browser automation is Playwright; skills are packaged, priced and traded for USDC on a marketplace whose code sits in the same directory as the memory store.

That last point is a structural observation rather than a criticism: src/memory/ contains bounty-*, commerce-*, marketplace-*, peer-reputation.ts and seller-bond-ledger.ts alongside reconsolidation.ts and spacing-effect.ts. The skills economy is modelled as memory that can be sold, which is a coherent position, and it means the directory's line count is not a measure of the memory system alone.

The Control UI shows dreams and skills. No surface there edits or deletes a memory record, and the epistemic directives ask the user questions in conversation whose answers return through ordinary extraction — so the person is a source, not an approver, and human_review is withheld.

9. Reliability, Safety, and Trust

The graph has three gates — lineage-gate.ts, structural-gate.ts and kg-entity-admission.ts — and a governance.ts recording provenance events. provenance_chain is a column on every chunk.

The honest assessment of the trust layer is that its pieces are individually sound and collectively unenforced at one point. There is no single place that decides what is admissible, the way oh-my-hermes routes everything through an admission state or The Librarian through one apply rule. Here the gates are per-subsystem, the audit log is per-caller, and the lifecycle is the one property with a designated reconciler — which is exactly why the lifecycle is also the property whose remaining bug is findable.

The dream engine grading itself on whether its output is later used (dream-evaluator.ts, dream-utility.ts) is a good idea and rare: a background consolidation process that measures its own value by downstream retrieval rather than by volume produced.

10. Tests, Evals, and Benchmarks

156 memory test files, and a benchmarks/arc-agi-3/ tree with a Python memory package. The tests are substantive: knowledge-graph.sabm.test.ts covers contradiction flagging without closing either edge, many-to-many relations deliberately not flagged, strengthen audit rows, supersession closing the edge, and the two belief-history assertions that earn negative_eval. consolidation.purge-expired.test.ts and consolidation.pairwise-cap.test.ts pin the forgetting path.

The gap is specific and matches the finding. temporal-filter.rel.test.ts tests only the relationship helper; the chunk helper has no test asserting that validAt produces a point-in-time query, which is why the excludeSuperseded interaction survives. And nothing tests selectBestVariant against an expired skill — which is why a filter naming a value from the wrong column has stayed in two queries.

11. For Your Own Build

Take the belief layer. Close an interval instead of deleting a row, keep one read that deliberately drops the active-only guard, and write the test that asserts the closed edge is absent from the ordinary read and present in the history. Those two assertions in one test are what make a temporal store trustworthy rather than merely temporal.

Take the canonical ledger's premise. If your retrieval is similarity-gated, the facts that matter most are the ones least likely to match — short, stable, low-entropy. Address those by key and inject them unconditionally rather than hoping the embedding finds them.

Do not carry two columns for one concept, even with a reconciler. The reconciler fixes writes and cannot fix a SQL string, and the failure it leaves is silent: a predicate naming a value the column never holds is not a type error, not a runtime error, and not a wrong-looking result — it is a filter that quietly does nothing. If a migration forces a second column, delete the first in the same release, or add a CHECK constraint naming the legal values so an impossible comparison becomes visible.

When you do write such a predicate, write it the way the retrieval query does: (col IS NULL OR col <> 'value'). SQL's three-valued logic turns a bare != into a silent exclusion of every NULL row, and a column added by migration is full of them.

Put the audit write where the mutation happens. Five subsystems each remembering to log is five places one can be forgotten; the chunk writer logging once is one place it cannot.

12. Open Questions

Whether lifecycle_state is meant to survive. It is the older of the two columns, the reconciler keeps it consistent, and the retrieval query still checks it — but every new value in the vocabulary is on the lifecycle side.

Whether buildTemporalWhereClause is waiting for a caller or is a leftover. It is exported, documented and typed; the relationship sibling that does have a caller was written later and explicitly declined to generalise it.

Whether the "FIRST IMPLEMENTATION in any agent memory system" claims on synaptic-tagging.ts and epistemic-directives.ts have been checked against prior work. They are stated as fact in source comments; no citation of a priority search accompanies them, and none was attempted here.

Appendix: File Index

Path What to read it for
src/memory/chunk-writer.ts:44-108 Two lifecycle types, one reconciler, and the audit it came from
src/memory/migrations.ts:56-76 The forward mapping, and the CASE branch for a spelling the writer never emits
src/memory/skill-version-resolver.ts:250-260 A filter on a value its column never holds
src/memory/knowledge-graph.ts:495-567 Belief history and as-of, the part worth copying
src/memory/temporal-filter.ts Two bitemporal vocabularies, and honesty about why they are not merged
src/memory/canonical-facts.ts:1-25 Why importance is orthogonal to similarity
src/memory/knowledge-graph.sabm.test.ts Both halves of the assertion, in one test

History

2026-09-16bc31e5e0… — first reading, at a commit dated 15 September 2026. Screened before opening, from a shallow clone; a dependency surface had changed inside the seven-day cooldown. Nothing was installed, built or run.