Version Retention (Keep Last N)
Context
Hot documents accumulate versions without bound. The knowledge system's graph
document sits at 545 versions; the soul's own index.md is at v56 and the
roadmap at v43. Generated documents are the worst case: mark_graph_publish
rewrites the whole graph document on every run, and the store's duplicate check
(prepareExistingDoc) only no-ops on byte-identical content, so nearly every
publish mints a version. The store has no delete path today. Versions are
immutable files kept forever.
Permanence is the right default for authored knowledge (the soul's pitch is "all versioned, all permanent"). It is the wrong default for generated artifacts whose history has near-zero value. Retention should therefore be targeted, not global.
Scope
Server-side store change plus one recognized publish-metadata key. No new verb, no protocol message changes. Clients gain a documented metadata key that already travels through the existing PUBLISH surface, plus a confirmation guard at the tool layer (see Accidental-set guard below).
Why pruning is structurally safe (verified against protocol/store)
- Versions live as immutable files
versions/<doc>/v{N}with a current symlink.CurrentVersion()is the max version present on disk (store.go:670) and the next version is current + 1 (store.go:975), so removing old files never disturbs numbering. VerifyChain(store.go:1197) verifies consecutive pairs among the versions actually present and never checks the oldest listed version'sprevious-hash. Removing a contiguous oldest prefix keeps the remaining chain verifiable. A mid-chain gap breaks it — this dictates the deletion order rule below.Geton a pruned version returns not-found and VERSIONS lists what remains. Both degrade cleanly.
Options Considered
A: Global server knob (-max-versions)
Simple, blunt. Prunes ADR and decision history with the same scythe as generated artifacts, and a store-wide destructive default contradicts the permanence ethos of a knowledge base. Rejected as the primary mechanism; could return later as an optional operator ceiling if abuse shows up.
B: Per-path retention config on the server
Deploy config maps path patterns to N. Keeps policy out of the data, but the
policy lives far from the publisher who actually knows a document is generated,
every deployment rediscovers the same rule for /graph.md, and it is awkward
through broker-fronted multi-world systems. Rejected.
C: Per-document retention declared at publish — recommended
The publisher sets retention: N in publish metadata. It is stored in the
version frontmatter as a recognized field and the store enforces it on every
subsequent write. Generated-document publishers (mark_graph_publish) declare
disposable history at the source; authored documents stay permanent by default
because absent retention means keep everything.
Trust consideration: any writer with publish capability can lower retention and destroy history. Acceptable — write capability already implies stewardship (the same capability can archive the document). Called out in the spec.
Plan: Option C, prune-on-write
Mechanics
retentionbecomes a recognized publish metadata key: integer, minimum 1, validated invalidateMeta(reject non-integer or < 1). It joins the recognized field set and serializes bare in frontmatter liketagsandimportance. The latest version's value is authoritative.- After a successful
Write(version file created, symlink flipped), if the just-written version carries retention R and more than R versions exist: delete version files oldest-first up to the cutoff (next − R). Abort on the first deletion error and log it — aborting preserves contiguity, soVerifyChaincan never observe a gap. The current version is never deleted; R ≥ 1 guarantees at least one version remains. - Backfill falls out for free: the first retained publish after upgrade prunes the whole backlog (545 → R) with no separate migration tool.
- Absent or removed retention means no pruning. Default behavior is unchanged everywhere.
- Legacy flat layout needs no special handling:
Writealready migrates a document to the per-doc layout before writing (migrateToPerDocDir), and pruning runs after the write, so prune only ever sees the per-doc layout.
Crash safety: pruning runs after the write is durable. A crash mid-prune leaves extra old versions, which the next write retries. Deletion is idempotent.
Concurrency: a reader fetching an old version concurrently with a prune gets
not-found. Acceptable and documented. The store is already lock-free with an
O_EXCL write guard; prune touches only versions strictly older than the
cutoff, never the current symlink target.
Accidental-set guard (tool layer)
The store prunes immediately on the publish that carries the key — an
accidental retention: 1 on a valued document destroys its history with no
in-store undo. Server semantics stay simple (a two-write engage rule was
considered and deferred; see Open questions). The guard lives where humans and
agents act:
- CLI —
demarkus publishwithretentionin-metawarns that pruning is destructive (showing how many versions the next write will delete, when cheaply known) and asks for interactive confirmation. A-yesflag skips the prompt for automation; a non-TTY invocation without-yesfails rather than silently confirming. - MCP tool descriptions (local
demarkus-mcp+ broker gateway) — themark_publishdescription states thatretentionpermanently deletes older versions on this and every subsequent write, and instructs the agent to confirm with the user before publishing with it. - Plugin gate — a PreToolUse hook on
mark_publish/mark_appendfires when metadata containsretention, at ask severity (same awk/bash machinery as the existing tag-gate and destination gate; no runtime deps). Covers the Claude Code path even when the agent ignores the description text.
mark_graph_publish is exempt from all three: it sets retention by design on
a generated document.
Surfaces
- store —
validateMeta+ recognized-field serialization + apruneVersionshelper called fromWrite(and via itWriteVersion;Appendlands throughWriteVersiontoo). - handler — no change expected; metadata already passes through.
- spec — document
retentionsemantics and the chain-verification interaction in the store/versions section ofdocs/SPEC.md. - MCP (local
demarkus-mcp+ broker gateway) — metadata objects already pass string values through; addretentionto themark_publishtool description on both surfaces (with the destructive-operation warning above).mark_graph_publishsets retention on the graph document — this is the concrete fix for the 545. - CLI —
-meta retention=20works once the key is recognized; publish command gains the confirmation prompt +-yesflag. - Plugins — demarkus-memory (and the knowledge plugin's KS-scoped gate) gain the retention ask-gate; separate plugin release with the usual pin bump.
Implementation steps
- Store: recognize and validate
retention; serialize in frontmatter; parse on read. - Store:
pruneVersions— list versions, sort ascending, delete oldest-first below cutoff, stop on first error, log every failure explicitly (no silent swallow, per guidelines). - Wire into the write path after success.
mark_graph_publishsets a default retention; surfaceretentionwith the destructive-operation warning inmark_publishdescriptions on both MCP surfaces.- CLI confirmation prompt +
-yesflag in the publish command. - Plugin ask-gate on retention in publish/append metadata (separate PR, its own version bump).
- Spec + docs.
- Tests,
bash pre-commit.sh.
Files to modify
protocol/store/store.go+store_test.godocs/SPEC.mdclient/cmd/demarkus-mcp/main.go(graph publish + tool description)client/cmd/demarkuspublish command (confirmation prompt +-yes)tools/demarkus-broker/internal/broker/mcp_tools_write.go,mcp_tools_graph.go(description parity)plugins/claude-code/hooks (retention ask-gate; separate PR)
Verification
- Keep-N on write: count and lowest remaining version correct.
VerifyChainpasses after prune (contiguous suffix).- Never deletes the current version; R = 1 keeps exactly the current version.
- Retention raised, lowered, or removed between writes behaves correctly.
- Injected deletion failure aborts without creating a gap.
- Backlog case: many existing versions, first retained write prunes them all.
- Flat-layout document migrates then prunes.
Getof a pruned version returns not-found; VERSIONS lists the remainder.- CLI: prompt shown when retention present;
-yesskips; non-TTY without-yesfails. - Plugin: gate fires on retention in metadata, stays silent otherwise (shell tests alongside the existing gate tests).
- Manual smoke: repeated
mark_graph_publishwith retention set, watch the versions directory stay bounded.
PR slicing
- PR 1 — store + spec + MCP descriptions + graph-publish default + CLI confirmation. Nothing ships half-done (conventions: never ship anything broken).
- PR 2 — plugin retention ask-gate + pin bump (plugin changes ride their own release train).
Knowledge-system rollout is the normal release train: server release, broker repin, deploy repo bump; the 545 clears on the next graph publish after upgrade.
Open questions
- Default retention for
mark_graph_publish— propose 20. Enough to debug a bad crawl, small enough to stay bounded. Alternatively make it a tool parameter with a default. - Should journals or other append-heavy soul docs adopt retention? Probably not — they are authored history. Decide per document, never globally.
- Two-write engage rule (retention only takes effect when the previous version also carried it) — considered as a server-side accident guard, deferred to keep store semantics simple; the tool-layer confirmation covers the accident path. Revisit if an accidental prune actually happens despite the guards.
- Operator ceiling (Option A as a supplement) only if a hostile or buggy writer becomes a real problem.
- Recovery story: pruned versions are gone from the store, but the GKE deploy keeps CSI volume snapshots, so catastrophic mistakes have a coarse undo.