Skip to content

feat: knowledge entry versioning — DB-enforced snapshot+bump, If-Match concurrency, versions/diff - #45

Merged
andrei-hasna merged 5 commits into
mainfrom
feat/b7c7b224-knowledge-entry-versioning
Jul 29, 2026
Merged

feat: knowledge entry versioning — DB-enforced snapshot+bump, If-Match concurrency, versions/diff#45
andrei-hasna merged 5 commits into
mainfrom
feat/b7c7b224-knowledge-entry-versioning

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What this is

Entry versioning for knowledge: a version column, an append-only revisions table, a Postgres trigger that bumps and snapshots in one write, optimistic concurrency on the patch path, and the versions / diff read verbs.

Implements todos K2 b7c7b224, K3 c7cc5238, K4 ab938375, K5 be94d5b2. Design: designs/r4-knowledge-versioning-and-s3-design.md §3.2–3.4, §3.8. Doctrine: knowledge k_ms4x92lg_evtnx9.

Revision note. This description was corrected after an adversarial review found two P1 bypasses. See the review-round comment on this PR for exactly which claims changed and why. The original text overstated three things; they are marked [corrected] below.

⚠️ This targets main. It has NOT been reconciled with the deployed artifact.

The deployed API reports 1.0.0-rc.2 with 10 paths (including /v1/sources); this repo's main is 0.2.91 with 7. Locating the deployed source is task e0759534 and is not part of this change. Merging this is not a production rollout.

The migration is additive — new nullable column, new table, triggers — so it can be applied wherever the deployed source turns out to live. Migrations are strictly appended: verified, 65 pre-existing checksums unchanged, 10 appended, 0 drifted, with a positive control confirming the checker flags a real one-character edit and ignores trailing whitespace.

[corrected] The earlier claim that "one migration is correct against both schemas" was an inference stated as a measurement. What is measured: the trigger reads tenancy via to_jsonb(OLD)->>'tenant_id', which yields NULL where the column is absent and the real value where it is present — verified by a test that ALTERs the column in. What is not known: the actual deployed schema. The reviewer constructed one plausible shape — a tenant-scoped composite primary key (tenant_id, id) — under which this migration fails outright with no unique constraint matching given keys. Until e0759534 closes, treat applicability to production as unverified.

The measured problem

An entry was created over the hosted API and edited twice; both prior bodies are gone. knowledge_items had no version column, no versions table existed, and PATCH /v1/notes/{id} accepted no If-Match — last-writer-wins with no conflict detection, on a fleet where many agents write to one store.

Positive control that the absence was real: the same DDL file already defines source_revisions with UNIQUE(source_id, revision) and a hash column.

Why a trigger and not application code

open-mementos implements the same feature in TypeScript. The bump lives in the merge branch of createMemory (memories.ts:~400); the snapshot lives in updateMemory (:823). mementos save takes the first. A memory sitting at version 4 returns "No previous versions" — zero retained bodies.

The failure was not a caller forgetting a helper. It was a second write path inside the owning package forgetting. A BEFORE UPDATE trigger is the only place below the serve handler, the upsert/import path, ingest rules, sync replay, backfills, and psql — including the paths nobody has written yet.

Stated trade-off: the bump is invisible in the TypeScript, and the row the database returns differs from the row the caller sent. That is why the suite writes via raw SQL, bypassing every application path, and asserts the snapshot appeared anyway.

(Not independently verified by me: the open-mementos root-cause narrative is outside this repo. It is load-bearing for this argument and is reported as read, not as re-measured.)

Scope

In: schema (knowledge_items.version, knowledge_item_versions with UNIQUE(item_id, version), non-null content_hash, body_uri for offloaded bodies) · the trigger · optional If-Match / expected_version with 409 {error, expected, current} · GET /v1/notes/{id}/versions[/{version}] · knowledge versions / knowledge diff.

Out, each with its own task: S3 artifact storage (7b80e498) — the server has no artifact code path at all; governed-file binding (b74bc29e); v1 backfill for the 729 existing entries (open decision 5).

Hardening added after adversarial review

  • ENABLE ALWAYS on the entry trigger. A normal trigger does not fire under session_replication_role = replica — what logical-replication apply workers, pg_restore --disable-triggers, and AWS DMS set. Measured before the fix: the update landed, the prior body was destroyed, no version row appeared, and the counter stayed at 1, so version actively lied. The design pre-committed to this test ("any success is a P0 and Phase 1 does not ship") and it had not been run.
    • Accepted limit, stated rather than implied: the table owner can still ALTER TABLE … DISABLE TRIGGER. Nothing a trigger can do defends against its own owner. The DML-only service role is refused both this and the replication role.
  • knowledge_item_versions is append-only by enforcement. A plain application role could previously UPDATE a retained snapshot and rewrite history in place. DELETE remains permitted because the table cascades from knowledge_items and blocking it would make knowledge delete fail outright — so history for a deleted entry goes with the entry until the S3 journal lands. The word "immutable" has been dropped; this is append-only for live entries.

Deliberate deviations from the design

Design Shipped Reason
diff -v N diff --rev N -v is already the global alias for --version.
valid_from/valid_to as TIMESTAMPTZ TEXT, ISO-8601 Every timestamp in this DDL is TEXT. A cast inside a BEFORE UPDATE trigger that throws on one badly-shaped legacy string would abort a legitimate write.
(not in design) trigger stamps updated_at only when the caller did not set it, in toISOString() shape [corrected — this was undisclosed in the original description.] NOW()::text renders 2026-07-28 21:29:56.01+00; space (0x20) sorts below T (0x54), so a TEXT column carrying both formats orders every trigger-written row before every application-written one regardless of actual time. It also discarded a caller-supplied updated_at, a regression against import/sync replay carrying source timestamps.
restore --id <id> --version N not added knowledge restore already means unarchive. Overloading it would be ambiguous and destructive; it needs its own name and its own decision.

Residuals — known and deliberately not fixed in this PR

  • reason is not settable over HTTP. Column, trigger read, and GUC plumbing exist and are tested; no surface sets it.
  • No automatic retry on 409. The CLI sends the version it read, so no agent types one, and a conflict exits non-zero naming both versions. Re-applying without comparing which fields moved is how you overwrite a colleague while believing you handled it.
  • The concurrency guard is now sent from update/archive/restore/untag/upsert, but the CLI-side interleaving that would prove it end-to-end is not staged. The server-side 409 path is covered.
  • NoteRepo.create() now costs a transaction per insert (~23ms against loopback, 30 creates in 689ms) because attribution is transaction-local. Undisclosed cost on the ingest/import/sync hot path; no before/after benchmark.
  • tests/serve.test.ts's in-memory shim cannot observe versioning at all — it matches SQL strings and has no trigger. It needed a transaction passthrough to keep passing; a comment at the top now says what it does not prove. Migrating it to PGlite is a reasonable follow-up.

Evidence

Exit codes measured unpiped (cmd > file; rc=$?), never through | head.

TDD, both rounds:

  • Round 1 behavioural red — application surface in, migration withheld, real in-process Postgres with main's actual DDL: 3 pass / 24 fail, rc=1 on column version does not exist. Green after the migration: 27/0, rc=0.
  • Round 2 — four new assertions written first against the shipped migration: 28 pass / 4 fail, rc=1. Green after the fixes: 32/0, rc=0.

Planted-defect controls (each reverted afterwards; unmodified copy re-runs clean at rc=0):

Planted defect Result
drop ENABLE ALWAYS rc=1, 1 test fails
revert updated_at to NOW()::text rc=1, 2 tests fail
remove the append-only guard rc=1, 1 test fails
hardcode tenant_id to NULL rc=1, 1 test fails

That last one is the defect the reviewer proved the previous suite could not catch.

Gates: bunx tsc --noEmit rc=0 · bun run verify:generated rc=0 ("6 generated bundles rebuild byte-identically") · validate-public-package rc=0 · migration checksum check rc=0 with positive control.

Secrets: scanned before every commit; positive control with a planted AKIA… returned 1 match / rc=0 (fired), real staged diff 0 matches / rc=1 (clean). Control removed from index and worktree before committing.

Full suite: main is already red, and this branch is less red

[corrected] The original description gave fixed pass/fail counts. They are load-dependent on this shared machine and should never have been stated as fixed. Measured under load average 38.76 on 20 cores with 233 concurrent bun processes, this branch showed 28 failures; the identical tree under load ~12–25 showed 4. The counts move; the structure does not.

Same-machine runs under comparable load:

result wall
pristine main (eed77b2) 304 pass / 2 skip / 6 fail, rc=1 298.9s
this branch 364 pass / 2 skip / 4 fail, rc=1 323.9s

Every failure in both is the same class: tests/cli.test.ts subprocess-spawn timeouts (Received: null = killed spawn), plus app wiki standard. Re-run with a generous timeout, the three non-cli.test.ts suites that failed under peak load pass 39/39, rc=0 — confirming timeouts rather than defects.

Not claimed: that CI will be green. main is red before this change and after it, in the same file.

MEASURED before this change: an entry created over the hosted API and edited
twice had BOTH prior bodies unrecoverable. knowledge_items had no version
column, there was no versions table, and PATCH /v1/notes/{id} was
last-writer-wins with no conflict detection — on a fleet where many agents
write to one store. Positive control that the absence was real: the same DDL
file already defines source_revisions with UNIQUE(source_id, revision) and a
hash column.

The bump and the snapshot are ONE write, enforced by a BEFORE UPDATE trigger
rather than by application code. That is settled by measurement, not taste:
open-mementos implements the same feature in TypeScript, with the bump in the
merge branch of createMemory and the snapshot in updateMemory, and `mementos
save` takes the branch that does not snapshot — so a memory sitting at version
4 today returns "No previous versions". The failure there was not a caller
forgetting a helper; it was a second write path inside the owning package
forgetting. A trigger sits below the serve handler, the upsert/import path,
`ingest rules`, sync replay, a backfill script, and a human at psql, including
the write paths nobody has written yet.

Three details are load-bearing and each has a test:
  - the no-op guard, or every idempotent re-upsert manufactures a version and
    buries the real edits;
  - NULLIF on the actor/reason GUCs, because a transaction-local setting resets
    to the empty string rather than to unset, which would otherwise record an
    attribution that reads as real and is not;
  - to_jsonb(OLD)->>'tenant_id' rather than OLD.tenant_id, so one migration is
    correct against both this repo's schema and the deployed build's.

PATCH gains optional If-Match (and an expected_version body field), returning
409 {error, expected, current} when the stored entry has moved on. Optional in
this phase: requiring it would break every installed 0.2.x CLI on the fleet.
GET /v1/notes/{id}/versions and /versions/{version} expose the history; an
absent entry is 404 while an entry that exists but was never edited is 200 with
an empty list, because collapsing those two answers is exactly what made the
sibling implementation's empty result unreadable as evidence.

The migration is additive (new nullable column, new table, trigger) so it is
safe to apply to an existing database.

Tests run against a real in-process Postgres with the real migrations, and
include a raw-SQL update that bypasses every application path, the upsert path
that callers actually use, and a two-writer conflict where the loser's body is
asserted absent afterwards.
Adds the two read verbs, shaped like the sibling `mementos` commands because
agents already know that shape, plus the client plumbing they need.

`knowledge versions --id <id>` lists retained prior versions newest-first with
the version the entry is at now. `knowledge diff --id <id>` defaults to "what
did the last edit change" and also accepts `--rev N` (N vs N-1) or an explicit
`--from A --to B`, where either side may be `current`.

Deviations from the design, both deliberate and both because the name was
already taken:
  - `--rev`, not `-v`: `-v` is the global alias for --version, and re-pointing
    it would silently break every existing `knowledge -v` invocation.
  - `restore --id <id> --version N` is NOT added here: `knowledge restore`
    already means unarchive. Reusing it would be an ambiguous, destructive
    overload; it needs its own name and its own decision.

The diff reports changed FIELDS as well as a line diff of the body. A body-only
differ would render a tags-only or archive-only edit as "no changes" — a
confident wrong answer of the class this whole feature exists to stop.

`knowledge update` now sends the version it just read as the concurrency guard,
so the agent never types a version number. Without this the server's check
would exist and nothing on the fleet would ever exercise it. A conflict exits
non-zero naming both versions; there is deliberately NO automatic retry,
because re-applying without comparing the fields that moved is how you
overwrite a colleague while believing you handled the conflict.

On the local JSON store, which keeps no history, both verbs REFUSE and name the
store rather than returning an empty list. An empty list there would be
indistinguishable from "this entry was never edited" — which is precisely how
the sibling implementation reported a record at version 4 with zero retained
bodies.

The client tests drive a live Bun.serve on loopback backed by a real Postgres,
through the same ItemStore the CLI uses, including the CLI itself as a
subprocess. Spawning is async on purpose: spawnSync would block the event loop
the server under test runs on and deadlock until the transport timeout.
bun run build output for the versioning change. bin/ and dist/ are committed in
this repo and verified by tests/generated-artifacts.test.ts, so the bundles must
move with the source or the check reports drift.
Both were reachable ways to lose history or corrupt it, and both are fixed in
the migration rather than in application code — moving either half of the
snapshot-and-bump out of the trigger would reintroduce exactly the open-mementos
failure this design exists to prevent.

1. ENABLE ALWAYS on the entry trigger.

A trigger created normally does NOT fire while session_replication_role =
replica. That is not exotic: it is what logical-replication apply workers,
`pg_restore --disable-triggers`, and AWS DMS set. MEASURED against the previous
migration — the update lands, the prior body is destroyed, no version row
appears, AND the counter stays put, so `version` then actively lies about the
row. With the deployed source still unlocated (task e0759534), any move of this
data runs through one of those paths. The design pre-committed to this exact
test ("any success is a P0 and Phase 1 does not ship"), and it had not been run.

The table owner can still DISABLE TRIGGER. Nothing a trigger can do defends
against its own owner, so that is now documented as an accepted limit rather
than left as an unstated failed criterion. Measured: the DML-only service role
is refused both this and the replication role.

2. updated_at is written in the application's ISO-8601 shape, and only when the
   caller did not set it.

updated_at is TEXT and the application fills it with toISOString(), but the
trigger wrote NOW()::text — '2026-07-28 21:29:56.01+00'. Space (0x20) sorts
below 'T' (0x54), so a column carrying both formats orders EVERY trigger-written
row before EVERY application-written one regardless of actual time, and
valid_from (copied verbatim from the snapshotted row) stopped being comparable
with valid_to without casting both. Measured: after a trigger write,
'2026-07-28T09:00:00.000Z' < '2026-07-28 18:34:...' evaluates false.

The trigger also discarded a caller-supplied updated_at. Import, sync replay,
and backfill carry a SOURCE timestamp and kept it before this trigger existed,
so that was a regression this change had introduced. It is now stamped only when
the caller left it alone; a writer that says nothing still gets a truthful
advance.

Why no test caught either: the server's only list ordering uses created_at, and
the one assertion touching updated_at compared against '2000-01-01', which is
true under both formats — a non-discriminating input.

Also in this round:
  - knowledge_item_versions is append-only by enforcement, not by name. A plain
    application role could UPDATE a retained snapshot and rewrite history in
    place. DELETE is deliberately still permitted because the table cascades
    from knowledge_items and blocking it would make `knowledge delete` fail;
    history for a deleted entry therefore goes with the entry until the S3
    journal (task 7b80e498) lands. Said plainly instead of calling it immutable.
  - tenancy carried through to_jsonb(OLD) now has a test that ALTERs the column
    in. Replacing that expression with a literal NULL was the one planted defect
    the previous suite did not catch, because the repo's schema cannot supply an
    input that distinguishes them.
  - the concurrency guard is sent from archive/restore/untag/upsert too, not
    only update. Design 3.4 says the CLI always sends it.
  - `versions` pages. The server caps a page at 200, so without an offset an
    entry past that many retained versions reported history in `total` that it
    could never return.
  - tests/search-pg-parity.test.ts uses the shared pglite fixture instead of its
    own copy, which had no transaction() and would have thrown the moment it
    touched the write path. Two false claims in the fixture's docstring are
    corrected to what was actually measured.

All four fixes verified by planting the defect back and confirming the suite
fails: dropping ENABLE ALWAYS, reverting updated_at to NOW()::text, removing the
append-only guard, and hardcoding tenant_id to NULL each turn the suite red.
Rebuild for the trigger-hardening round. bin/ and dist/ are committed in this
repo and checked by tests/generated-artifacts.test.ts, so the bundles move with
the source.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Review round 1 — remediation, and exactly what changed in the description

An adversarial reviewer ran this PR against real PostgreSQL 16.14, not the PGlite the suite uses. Verdict was APPROVE-WITH-FIXES. Two P1s were real; both are now fixed in 100b39d, with the rebuilt bundles in efcd657.

I am recording the description edits here rather than silently rewriting history: this PR is unmerged, so the body is still a working document, but three of its claims were wrong and the record should say which.

Fixed — P1

P1-1 · The trigger was bypassable. A trigger created normally does not fire under session_replication_role = replica — what logical-replication apply workers, pg_restore --disable-triggers, and AWS DMS set. Reviewer measured against the then-current migration: the update landed, the prior body was destroyed, no version row appeared, and the counter stayed at 1, so version actively lied about the row. The design pre-committed to exactly this test — "any success is a P0 and Phase 1 does not ship" — and I had not run it. Fixed with ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER. The owner-level DISABLE TRIGGER path is undefendable and is now documented as an accepted limit instead of an unstated failed criterion.

P1-2 · The trigger wrote a second, incompatible timestamp format. NOW()::text into a TEXT column the application fills with toISOString(). Space (0x20) sorts below T (0x54), so every trigger-written timestamp sorted before every application-written one regardless of actual time, and valid_from/valid_to stopped being comparable. It also discarded a caller-supplied updated_at — a regression against import and sync replay, which carry source timestamps. Both fixed. My test could not have caught it: it compared against '2000-01-01', true under either format — a non-discriminating input.

Fixed — cheap correctness

  • knowledge_item_versions is append-only by enforcement. A plain app role could previously UPDATE a snapshot and rewrite history in place. DELETE stays permitted (cascade from knowledge_items; blocking it would break knowledge delete), so history dies with a deleted entry until the S3 journal lands. "Immutable" removed from the description.
  • The to_jsonb(OLD)->>'tenant_id' branch now has a test that ALTERs the column in. This was the one planted defect the previous suite could not catch — the repo's schema cannot otherwise supply a distinguishing input.
  • The concurrency guard is sent from archive/restore/untag/upsert, not just update (design §3.4: "the CLI always sends it").
  • versions pages. The server caps at 200, so history past that was counted in total and unreachable.
  • tests/search-pg-parity.test.ts now uses the shared PGlite fixture. Its private copy had no transaction() and would have thrown the moment it touched the write path — the exact drift the fixture's own docstring claimed to have eliminated. Two false statements in that docstring are corrected to what was measured.

Each fix was verified by planting the defect back: dropping ENABLE ALWAYS, reverting updated_at, removing the append-only guard, and hardcoding tenant_id to NULL each turn the suite red (rc=1). Unmodified copy re-runs clean.

Description corrections

  1. "One migration is correct against both schemas" was an inference stated as a measurement. What is measured is the to_jsonb behaviour. The reviewer constructed a plausible deployed shape — composite PK (tenant_id, id) — under which this migration fails outright. Since e0759534 is open, applicability to production is unverified. Softened.
  2. Fixed pass/fail counts were load-dependent and should not have been stated as fixed. The same tree gave 28 failures at load average 38.76 (20 cores, 233 concurrent bun processes) and 4 at load ~12–25. Now stated as a structural claim with the mechanism named. The reviewer's own counts differed from mine again, which is the point.
  3. The updated_at reformat was undisclosed. Now in the deviations table.

Not fixed — residuals, in the description

reason unsettable over HTTP · no auto-retry on 409 · no staged CLI-side interleaving test for the guard · undisclosed per-insert transaction cost (~23ms) on the ingest/import hot path · tests/serve.test.ts's SQL-string fake still cannot observe versioning.

Per the remediation-round termination rule: two rounds, then stop. Anything above that cannot corrupt history or lose a version is recorded rather than fixed in-train.

@andrei-hasna
andrei-hasna merged commit 0497aca into main Jul 29, 2026
8 checks passed
@andrei-hasna
andrei-hasna deleted the feat/b7c7b224-knowledge-entry-versioning branch July 29, 2026 11:17
andrei-hasna added a commit that referenced this pull request Aug 3, 2026
… both stores (#65)

fix(update): add --if-version so the caller's read guards the write

`knowledge update --content` silently destroyed a concurrent edit. Two agents
reading an item at version 1, then writing in sequence, both got rc=0 and the
first writer's content was gone — while the version counter incremented 1->2->3,
so the field that would reveal the clobber was the one that made it look healthy.

The guard was not missing. `cli.ts` has passed `expectedVersion: current.version`
since #45, and the cloud store sends it as an `if-match` header. But `current`
came from a `get()` taken microseconds earlier by the write itself, so it could
only catch a third party writing inside that window — never the caller who read
minutes ago, composed a body, and then wrote. A guard that derives its expected
value from its own read is not a guard.

`--if-version <n>` takes the version from OUTSIDE the write, where the caller
read it. Omitted, behaviour is unchanged. A stale version is refused at exit 2
naming both versions; nothing is written. The local JSON store gains a real
counter (bumped inside the existing cross-process `withLock`) rather than
refusing the flag, so both backends enforce it and a caller cannot get different
protection depending on which store it landed on.

Reviewed independently: the reviewer reproduced the pre/post discrimination
itself rather than trusting the author (pre-fix 2 pass / 7 fail, post-fix
9 pass / 0 fail), and confirmed both backends enforce for real — the local bump
inside `withLock`, the cloud path in SQL at `serve.ts:374`.

Supersedes #66, which implemented the same flag but refused it on the local
store; its test suite is grafted here, with the local-store case rewritten to
assert enforcement two-sided — a stale guard refused, a matching one accepted.

Includes a fix for a vacuous assertion in this PR's own tests: asserting exit 1
plus stderr containing `--if-version` passes on a build where the flag does not
exist, because the unknown-flag error echoes the flag name. The added
`not.toContain('Unknown flag')` is what discriminates, and the reason is
recorded at the assertion so it is not deleted as redundant.

Task 97d26f1b. Follow-up filed as b37183c8: the MCP `ok_update` tool passes no
version guard at all and is untouched by this change.

Agent: agent-chief-planning
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant