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
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 supersedegains-d/--diff,--revisit-after, and--expires-when(#31) — parity withselvedge 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
- site: selvedge.sh
- repo: github.com/masondelan/selvedge
- release notes: v0.3.11
- the chain design doc, including everything it deliberately doesn't protect: docs/design/tamper-evidence-proposal.md
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 (8)
A rejection row that carries an expires_when now has two clocks on it, and only one of them is checked by selvedge verify. The chain answers a past-tense question, whether this rejection is the one written, and chain_intact answers it down to the sequence number. expires_when answers a present-tense question, whether the rejection is still true of the world, and the evaluator behind selvedge stale settles that only when something runs it. Nothing in an append-only store evaluates a predicate on its own; a row whose condition fired last week is as intact as the day it was chained, and intact is exactly what verify reports.
The place this bites is delivery, the part v0.3.10 solved. The digest at session start injects memory deterministically, which is the fix for an agent that never volunteers a read. What the post does not say is whether that digest runs the evaluator before it injects, or injects and leaves stale as a command someone remembers to type. If it is the second, a rejection past its expiry arrives with the same authority as a live one, and the agent is told no by a ruling its own evaluator would call dead. A row the evaluator returns as manual_review makes this sharper: the evaluator has already said it cannot resolve that one locally, so the honest delivery is the rejection with the flag on it.
The field report you asked for has a cheap first form in the dogfood store: rows with an expires_when that has fired, against rows the last digest delivered. If the second set contains the first, the chain is certifying advice the evaluator would call stale, and whether anything appends the supersede that closes a dead no is the open question under the field report.
the Claude Code SessionStart digest already called
get_stale_decisionsbefore rendering in v0.3.11. but your concern about what actually reached the agent was valid: the revisit section dropped the evaluator's expiry/manual-review annotations, and the rejection section only explainedstale_whenmatches. an expired rejection could therefore arrive without the explanation that should qualify it.that gap is fixed in v0.3.13. expiry and manual-review labels, including their explanations, now survive both sections. annotations are matched by event ID so a sibling decision on the same path cannot supply the wrong status. superseded decisions also leave the revisit list. the regression tests cover those cases.
one distinction i want to preserve: a fired condition means the decision needs review; it doesn't prove the rejected approach is now sound. closing that loop still takes an explicit
supersedewith the new reasoning. the session-start hook doesn't append one automatically, andverifycontinues to check integrity only.your proposed comparison is useful: for any expired decision that is delivered, the review explanation needs to travel with it. thanks for pressing on the delivery behavior rather than stopping at the evaluator existing.
Matching the annotation by event ID rather than by path is the part of v0.3.13 I would have asked for next, because a sibling decision on the same path was exactly how the wrong status could travel, and the distinction you preserve is the right one: a fired condition reopens the question, it does not answer it, and the supersede has to carry the new reasoning or the store ends up with a dead no replaced by an unexplained yes.
One thing the delivered row could carry to make that review cheap is the original reasoning next to what fired. A reviewer who sees only expired has to reconstruct why the rejection was made before deciding whether the world has actually changed; a reviewer who sees the reasoning and the condition side by side can answer in one look, and can also tell an expiry that is about time from one that is about a fact.
That leaves one number worth printing from the dogfood store: how many rejections are past their condition and not yet superseded, and how long they stay that way. If that count grows, the evaluator is doing its job and nobody is closing the loop, and the field report you wanted has its first line.
I hear you, Edward. We’ll likely address this in the next implementation phase for Selvedge.
The gap you named precisely - "someone who controls the file can recompute every digest" - is exactly what external anchoring closes. Registering the chain root hash to Sigstore Rekor (a public append-only log, free, one POST) before the session ends means any recomputation would also need to forge an entry in a log maintained by the Linux Foundation, which it can't. The local chain you've built is the right first layer; an external anchor is the second, and it doesn't require a key or a trusted server on your side - just a hash you emit once and can verify against a public URL forever.
agreed — an independently retained checkpoint is a useful next layer. for this chain, i'd anchor the current head hash and sequence number, with the verification reference retained outside the mutable database. that gives a verifier something independent to compare against if the covered history is rewritten. writes after the latest checkpoint still have the local chain's threat model.
one qualification on Rekor: the documented upload workflow includes a signature and public key. Sigstore's keyless flow can avoid managing a long-lived signing key, but it still uses signing material and identity verification. i wouldn't describe the integration as an unsigned hash POST.
external anchoring hasn't shipped in selvedge. i'd keep it opt-in to preserve local operation, with a clear statement of which checkpoint was verified and which later events remain unanchored. thanks for making the external-witness requirement concrete.
expires_when covers the rejection whose premise moved, but not the rejection that was wrong when it was written, and those two rot differently. exact ranks above proximity_high because the outcome was stated, which is a provenance property rather than a correctness one, so a bad reject now outranks a correctly inferred one and nothing downstream can tell. Is there a counter-event for that, or does overturning a reject mean writing a second reject about the first?
yes —
supersedeis the counter-event for this. you can target the original rejection directly and record why it was wrong:that appends a linked event, preserves the original rejection, and makes
prior_attemptsreport the outcome asreopened. it does not require waiting for an expiry condition to fire. the correction workflow is documented here.you're right about
exact: it means the outcome was explicitly recorded. it doesn't establish that the reasoning was correct, and a consumer treating it as a truth score would be over-reading the field. the correction mechanism exists, but the store cannot discover a mistaken premise by itself.expires_whensupplies a reason to review;supersederecords the actual change of judgment.