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
co-authored by Claude Opus 5
parent 2b85443dd1
commit 35f3f09d12
7 changed files with 767 additions and 21 deletions
+29
View File
@@ -56,6 +56,19 @@ export interface SnippetUsage {
last_pulled_at: string | null;
}
/** Result of the last drift check — does the recorded location and code still
* match source? The check runs agent-side (Scribe has no checkout); this is the
* remembered verdict. `current` is false once the snippet has been edited since
* the check, at which point the verdict describes code that's no longer there. */
export interface SnippetVerification {
status: "ok" | "missing" | "moved" | "changed" | "unverified";
current: boolean;
checked_at: string | null;
detail?: string | null;
path?: string | null;
needs_attention?: boolean;
}
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
* field here is a truncated *body preview* (the knowledge feed's naming), not
* the parsed fields above. */
@@ -73,6 +86,9 @@ export interface SnippetListItem {
owner?: string | null;
/** Always present from the backend, zero-filled for records with no events. */
usage?: SnippetUsage;
/** Present on the detail record; the list feed carries it when a check has
* been recorded. */
verification?: SnippetVerification;
}
/** Create/update payload — discrete fields the backend serializes into the
@@ -103,6 +119,8 @@ export async function listSnippets(
repo?: string;
path?: string;
symbol?: string;
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
verification?: string;
} = {},
): Promise<{ snippets: SnippetListItem[]; total: number }> {
const qs = new URLSearchParams();
@@ -112,6 +130,7 @@ export async function listSnippets(
if (params.repo) qs.set("repo", params.repo);
if (params.path) qs.set("path", params.path);
if (params.symbol) qs.set("symbol", params.symbol);
if (params.verification) qs.set("verification", params.verification);
const query = qs.toString();
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
}
@@ -135,6 +154,16 @@ export async function deleteSnippet(id: number): Promise<void> {
return apiDelete(`/api/snippets/${id}`);
}
/** Record a drift-check verdict. The check itself runs where the code is — an
* agent with the working tree — since Scribe has no checkout. This stores what
* was found, and is how the UI clears a stale marker after a manual fix. */
export async function verifySnippet(
id: number,
verdict: { status: string; detail?: string; path?: string },
): Promise<Snippet> {
return apiPost(`/api/snippets/${id}/verify`, verdict);
}
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
export async function mergeSnippets(