DEV Community

Cover image for Your agent's memory needs the word 'no' — and a way to prove nobody edited it
Mason Delan
Mason Delan

Posted on

Your agent's memory needs the word 'no' — and a way to prove nobody edited it

the expensive failure in AI-assisted coding isn't a bad line of code — linters and tests catch those. it's an agent confidently re-implementing something your team already tried and killed. that's been the selvedge pitch from the start: an append-only store where the agent logs its own reasoning at change time, so "we tried that and it broke revocation" survives the session that learned it.

v0.3.10 solved delivery — the memory now arrives at session start instead of waiting to be asked ("Delivery, Not Storage" [arXiv:2607.20972] measured why that matters: zero voluntary memory operations across 114 turns against a pre-seeded store, while deterministic injection landed every time).

v0.3.11 is about the two halves of the sentence "rejected paths, retained." first: making the rejection itself first-class. second: making retained something you can check instead of something you take my word for.

rejections are now stated outcomes, not inferences

until now, selvedge could tell you a path was abandoned mostly by reading tea leaves: a remove event near an add, a proximity window, a heuristic confidence score. useful, but it's inference — and inference is exactly what this tool exists to replace with testimony.

three changes make abandonment a stated fact:

change_type="reject". the counterpart to revert, for paths never taken. revert says "we tried this and rolled it back." reject says "we considered this and decided against it" — no code was ever written, and the decision is still worth a permanent record, because the next session's agent will consider it too. no new MCP tool; it's a value on the existing log_change.

prior_attempts grows an exact tier. an attempt closed by an explicit reject or revert event now reports confidence: "exact" — the outcome is stated in the log, not inferred from proximity. the old heuristic drops to tiebreaker for attempts without a stated outcome. (if you filter on confidence, accept exact alongside proximity_high — rows move.)

expires_when finally does something. a rejection stored without the condition that would invalidate it is data that silently rots. the column shipped dormant in v0.3.8; v0.3.11 ships the evaluator, with a deliberately closed grammar — four shapes, validated at write time, everything else rejected rather than stored:

library:django>=5.0      # revisit when a named dependency reaches a version
entity:users.email:changes   # revisit when that entity next changes
date:2027-01-15          # revisit on a date
manual:security-review   # never auto-fires; a label for humans
Enter fullscreen mode Exit fullscreen mode

selvedge stale evaluates these locally — installed package metadata, the event log itself, and the clock. no network, no LLM, and no cleverness: a library: condition whose package isn't locally observable comes back as manual_review, not a guess, and a pre-release never counts as its final release. a decision that knows when it should die is a decision you can still trust in a year.

capture-time nudges close the loop: log a reject or revert with neither stale_when nor expires_when and the validator suggests recording the invalidating condition. it warns, never rejects — same posture as the secret-shape warnings.

the log can now prove itself

here's the uncomfortable question under the whole product: selvedge's differentiator is that rejected decisions stay on the record. append-only. permanent. queryable. and until this release, that was a promise about our own conduct — nothing in the store could show you a row was the row the agent wrote.

v0.3.11 adds a tamper-evident hash chain. every logged event gets a SHA-256 chain record in a sidecar table, written in the same transaction, each record binding the previous one and its own sequence number. selvedge verify gains two checks: chain_intact recomputes the whole thing and fails hard — naming the exact sequence number — when a chained row was edited, deleted, or reordered out-of-band. chain_coverage warns (never fails) about rows that predate the chain: unchained, not invalid.

the design problem worth a paragraph, because it generalizes past selvedge: some fields are late-bound. the commit SHA does not exist when the event is logged — the event describes the work that produces the commit, and a post-commit hook stamps it in afterward. a naive "hash the whole row" chain calls your own git hook an attacker. selvedge's cut: git_commit sits outside the protected core, declared out loud in the attestation manifest — tolerable because a commit hash is independently checkable against git, which is itself a hash chain maintained by other software. everything else — reasoning, diff, entity path, change type, the supersede links — is chained. legitimate mutations don't break anything: migrate-paths appends an amend record, and the destructive-gated prune appends a tombstone accounting for what it deleted. a gated prune verifies clean; a silent sqlite3 "UPDATE events SET reasoning=..." does not.

and the honest scope, which is in the module docstring and belongs in this post too: this detects casual and accidental modification, and produces an independently verifiable export. it is not proof against a motivated local attacker. there's no key. someone who controls the file can recompute every digest. what the chain buys you is that nobody edits the record casually — no script, no well-meant cleanup, no buggy future code path mutates a decision without the next selvedge verify naming the exact row.

we ate the dogfood on day one: selvedge's own store crossed its chain genesis with this release, and the first chained rows are the decisions behind v0.3.11 — including a real change_type="reject" for the retroactive chain seal command we decided not to ship, with the reasoning attached.

the smaller items

  • selvedge supersede gains -d/--diff, --revisit-after, and --expires-when (#31) — parity with selvedge log, through the same size-bound and secret-shape checks.
  • an id-less supersede no longer re-opens every earlier revert on the path (#30) — the fallback now mirrors the auto-link rule and re-opens at most the one removal it would have linked to at write time.
  • both were disclosed as known-and-unfixed in the v0.3.10 post; both are closed now.
  • hook contracts, pinned: the PreCompact reminder is byte-identical across repeat fires, goes quiet once you log, and now distinguishes "edited with no log" from "log exists but was truncated" — different problems, different fixes. the SessionStart digest's selection order is a documented contract with a seeded regression fixture for the supersede-mid-render race.

numbers: tests 984 → 1114, coverage 89.4%. the chain checks are mutation-verified — delete the threading checks and the suite goes red. the resident MCP tool-surface cost moved this release (4445 tokens core, 2.2% of a 200k window) because the rejection example and the expires_when grammar now live in the tool docstrings agents actually see; that's the adoption bet, called out in the changelog.

upgrading from 0.3.10

pip install -U selvedge. genesis is automatic on your first write; there's no migration and no new config keys. one expected surprise: selvedge verify will warn about your pre-existing rows (chain_coverage) — they're unchained, not invalid, and the chain deliberately makes no claim about rows that predate it. CI stays green by default.

try it

pip install selvedge
cd your-project
selvedge setup        # detects claude code / cursor / copilot
Enter fullscreen mode Exit fullscreen mode

open source, MIT, local-first, zero LLM calls in the core. the thing i most want field reports on this time: log a real rejection with an expires_when, wait for it to fire, and tell me whether the revisit that surfaced was worth your attention.

Top comments (0)