feat: knowledge entry versioning — DB-enforced snapshot+bump, If-Match concurrency, versions/diff - #45
Conversation
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.
Review round 1 — remediation, and exactly what changed in the descriptionAn 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 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 — P1P1-1 · The trigger was bypassable. A trigger created normally does not fire under P1-2 · The trigger wrote a second, incompatible timestamp format. Fixed — cheap correctness
Each fix was verified by planting the defect back: dropping Description corrections
Not fixed — residuals, in the description
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. |
… 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
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 theversions/diffread verbs.Implements todos K2
b7c7b224, K3c7cc5238, K4ab938375, K5be94d5b2. Design:designs/r4-knowledge-versioning-and-s3-design.md§3.2–3.4, §3.8. Doctrine: knowledgek_ms4x92lg_evtnx9.main. It has NOT been reconciled with the deployed artifact.The deployed API reports
1.0.0-rc.2with 10 paths (including/v1/sources); this repo'smainis0.2.91with 7. Locating the deployed source is taske0759534and 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 yieldsNULLwhere the column is absent and the real value where it is present — verified by a test thatALTERs 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 withno unique constraint matching given keys. Untile0759534closes, 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_itemshad no version column, no versions table existed, andPATCH /v1/notes/{id}accepted noIf-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_revisionswithUNIQUE(source_id, revision)and a hash column.Why a trigger and not application code
open-mementosimplements the same feature in TypeScript. The bump lives in the merge branch ofcreateMemory(memories.ts:~400); the snapshot lives inupdateMemory(:823).mementos savetakes 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 UPDATEtrigger is the only place below the serve handler, the upsert/import path,ingest rules, sync replay, backfills, andpsql— 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-mementosroot-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_versionswithUNIQUE(item_id, version), non-nullcontent_hash,body_urifor offloaded bodies) · the trigger · optionalIf-Match/expected_versionwith409 {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 ALWAYSon the entry trigger. A normal trigger does not fire undersession_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, soversionactively 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.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_versionsis append-only by enforcement. A plain application role could previouslyUPDATEa retained snapshot and rewrite history in place.DELETEremains permitted because the table cascades fromknowledge_itemsand blocking it would makeknowledge deletefail 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
diff -v Ndiff --rev N-vis already the global alias for--version.valid_from/valid_toasTIMESTAMPTZTEXT, ISO-8601BEFORE UPDATEtrigger that throws on one badly-shaped legacy string would abort a legitimate write.updated_atonly when the caller did not set it, intoISOString()shapeNOW()::textrenders2026-07-28 21:29:56.01+00; space (0x20) sorts belowT(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-suppliedupdated_at, a regression against import/sync replay carrying source timestamps.restore --id <id> --version Nknowledge restorealready 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
reasonis not settable over HTTP. Column, trigger read, and GUC plumbing exist and are tested; no surface sets it.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 theingest/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 atransactionpassthrough 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:
main's actual DDL: 3 pass / 24 fail, rc=1 oncolumn version does not exist. Green after the migration: 27/0, rc=0.Planted-defect controls (each reverted afterwards; unmodified copy re-runs clean at rc=0):
ENABLE ALWAYSupdated_attoNOW()::texttenant_idtoNULLThat last one is the defect the reviewer proved the previous suite could not catch.
Gates:
bunx tsc --noEmitrc=0 ·bun run verify:generatedrc=0 ("6 generated bundles rebuild byte-identically") ·validate-public-packagerc=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:
mainis 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
bunprocesses, 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:
main(eed77b2)Every failure in both is the same class:
tests/cli.test.tssubprocess-spawn timeouts (Received: null= killed spawn), plusapp wiki standard. Re-run with a generous timeout, the three non-cli.test.tssuites that failed under peak load pass 39/39, rc=0 — confirming timeouts rather than defects.Not claimed: that CI will be green.
mainis red before this change and after it, in the same file.