feat(snippets): drift check — verify a snippet still matches its source
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped

A recorded snippet points at a repo · path · symbol that WILL rot: files
move, symbols get renamed, implementations diverge from the copy stored
here. Nothing detected any of it, so a record degraded silently from
"canonical reference" to "confidently wrong" — worse than no record, since
it is surfaced with the same authority either way.

WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design
question to settle first. Scribe has no checkout of the operator's repos
and must not acquire one: giving the server repo access would make every
install a credential problem and break instance-agnosticism (rule #115).
The agent already has the working tree, so it does the comparing; the
server remembers the verdict, makes it queryable, and knows when it has
expired. New MCP tool verify_snippet teaches the four-step procedure and
records the result; a REST endpoint mirrors it so the UI can clear a
marker after a manual fix.

WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was
checked against. Invalidating it on edit means deciding which edits count
— a when_to_use tweak shouldn't void a code check, a rewrite must — which
is fiddly and easy to get subtly wrong, and easy for a new write path to
forget entirely. Stamping the verdict with a hash sidesteps all of it: one
whose code_sha no longer matches is self-evidently expired, computed at
read time, no invalidation branch to maintain.

That makes "expired" the interesting filter case. It is not `drifted`
(nothing was found wrong) and not `unverified` (a check did happen), yet
it plainly needs looking at — so `verification=attention` covers both. To
keep that one index-served predicate rather than a post-filter that would
make the pagination total a lie, data now also mirrors the CURRENT code's
fingerprint as data.code_sha, and a jsonpath compares the two fields
within the row. The filter is implemented in both dialects, SQL and
Python, for the same reason the location filter is: the semantic arm's
candidates arrive already fetched.

A merge deliberately carries no verdict forward — the survivor's code is a
union of several sources, so no prior check describes it, and unverified
is the honest answer.

UI: a danger-toned drift badge on each card (an actively misleading record
outranks a merely unused one), and a "Needs attention" filter. Its empty
state says plainly that never-verified snippets don't appear there —
otherwise `attention` would mean "everything" on day one and be useless as
a worklist.

Refs #2086

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-07-28 18:20:33 -04:00
parent 2b85443dd1
commit 35f3f09d12
7 changed files with 767 additions and 21 deletions
+41 -1
View File
@@ -62,10 +62,12 @@ async def list_snippets_route():
repo = request.args.get("repo", "")
path = request.args.get("path", "")
symbol = request.args.get("symbol", "")
# Drift check (#2086). "attention" is what the UI's filter chip sends.
verification = request.args.get("verification", "")
limit, offset = parse_pagination()
items, total = await snippets_svc.list_snippets(
uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
repo=repo, path=path, symbol=symbol,
repo=repo, path=path, symbol=symbol, verification=verification,
)
# Mark rows owned by someone else so the UI can show whose they are — an
# unmarked row in your own list reads as one you recorded and vetted.
@@ -203,6 +205,44 @@ async def update_snippet_route(snippet_id: int):
return jsonify(out)
@snippets_bp.route("/<int:snippet_id>/verify", methods=["POST"])
@login_required
async def verify_snippet_route(snippet_id: int):
"""Record a drift-check verdict. Body: {"status": ..., "detail", "path"}.
The CHECK itself runs wherever the code is — an agent with the working tree
— because Scribe has no checkout and shouldn't have one. This endpoint just
stores what was found. It's here for parity with the MCP tool and so the UI
can clear a stale marker after the operator fixes a record by hand."""
uid = get_current_user_id()
if await _load_snippet(uid, snippet_id) is None:
return not_found("Snippet")
# Distinguished from not-found deliberately: the service returns None for
# both, and telling a shared reader "no such snippet" about one they can
# plainly see is a confusing lie.
if not await can_write_note(uid, snippet_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json() or {}
status = (data.get("status") or "").strip()
if not status:
return jsonify({"error": "status is required"}), 400
try:
updated = await snippets_svc.record_verification(
uid, snippet_id,
status=status,
detail=data.get("detail") or "",
path=data.get("path") or "",
)
except ValueError as exc:
# An unknown status — reject it rather than storing a value the filter
# would then never match.
return jsonify({"error": str(exc)}), 400
if updated is None:
return not_found("Snippet")
return jsonify(snippets_svc.snippet_to_dict(updated))
@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
@login_required
async def merge_snippet_route(snippet_id: int):