fix(knowledge): the browse vocabulary catches up three kinds, and a snippet's mirror survives the generic door (#3128 recs 2-6)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 33s

Spike #3128 found the storage sound and the retrieval vocabulary frozen
before `issue` shipped (0065). Five things, in the order they had to land.

**The mirror (rec 5, the data-integrity one).** `notes.data` is DERIVED from
a snippet's body, but only `update_snippet` knew that. `update_note` is a
hasattr loop with no snippet awareness, and both doors reach it — so PATCH
/api/notes/<snippet_id> {body} rewrote the body and left the mirror behind.
`snippet_fields` PREFERS the mirror, so the row went on reporting its old
repo/path/symbol to the location reverse lookup and to prior-art recall while
displaying its new body: surfaced with full authority, and wrong.
`snippets.recompose_data` rebuilds it from the body, carrying `verification`
and `provenance` (neither is in the body to parse). An explicit `data` still
wins, so every snippet-service write is untouched.

**One facet table (rec 3), before adding any facet.** The type predicate was
written three times — SQL, Python over semantic candidates, and a ternary
computing the `is_task` pre-filter — and agreed only by luck. Adding `issue`
to the SQL arm alone would have set the pre-filter to is_task=False, handed
the Python arm a candidate set with no tasks in it, and returned an empty
semantic half for the Issues facet forever with nothing red. `_FACETS` now
generates all three. The Python arm also regains the `status IS NULL` half its
SQL twin always had.

**Issue and spike become facets (rec 2).** 435 issues — 17% of every task —
were filterable nowhere on the human surface, while retired `plan` (90 rows)
had a chip of its own. `_VALID_TYPES` was a hand-kept copy and is now derived.
`plan` stays a valid facet for its legacy rows; it loses its chip.

**Snippets stop being half-present in the feed (rec 4).** All 90 were in the
All list, in no count, wearing an empty badge, and opening in the note editor.
Counts now group by task_kind — every kind for the same two round-trips, which
is why `issue` had no number — and total includes snippets, so the All chip
matches the list it labels. Snippet cards route to /snippets/:id.

**The prose that excused it (rec 6).** `snippet_fields` and the `data` column
both still said pre-0070 rows were "never backfilled". True when 0070 landed,
false since `backfill_snippet_data` shipped, and it read as licence for a
stale mirror.

Tests: the pre-filter can never exclude a row its own facet accepts (the
regression, parameterised over every facet); both dialects select exactly
their own rows; an unknown facet matches nothing; the mirror follows a body or
title write, carries the verdict, and yields to an explicit `data`.
`compiled_sql` moves to tests/helpers rather than becoming a third copy.

Write-up: note #3161.
This commit is contained in:
2026-08-28 12:06:43 -04:00
parent d0a2733cb6
commit f80401d58e
11 changed files with 576 additions and 104 deletions
+50 -4
View File
@@ -540,14 +540,60 @@ def compose_data(
return out
def recompose_data(note) -> dict:
"""Rebuild a snippet's `data` mirror from its own body, title and tags.
For the GENERIC note door. `update_snippet` composes the mirror itself from
the field set it just merged and never needs this; a plain
`update_note(body=...)` — which the Knowledge feed's editor issues, because
a snippet card there routes to /notes/:id — has no idea the mirror exists,
and left it stale. `snippet_fields` then PREFERS the stale mirror, so the
row reported its old repo/path/symbol to prior-art recall while displaying
its new body: confidently wrong, which is worse than no record (#3128).
The body is the authority; the mirror is derived. That is already the rule
this file states — it just had no enforcement on the path that bypasses
`update_snippet`.
`verification` and `provenance` are CARRIED, not recomposed, because
neither is in the body to parse — the same carry `compose_data` does for
the snippet service's own writes. Note that a verdict does not need
invalidating here: `code_sha` is recomputed from the new code, so a stale
verdict expires itself on read exactly as it does after any other edit.
"""
parsed = parse_snippet_fields(note.title, note.body, note.tags)
prior = note.data or {}
return compose_data(
name=parsed["name"],
when_to_use=parsed["when_to_use"],
signature=parsed["signature"],
language=parsed["language"],
code=parsed["code"],
locations=parsed["locations"],
merged_from=parsed["merged_from"],
verification=prior.get("verification"),
provenance=prior.get("provenance"),
)
def snippet_fields(note) -> dict:
"""Structured fields for a snippet, preferring the indexed `data` column and
falling back to parsing the body.
Both paths must agree, because rows written before migration 0070 have no
`data` and are never backfilled — a hand-edited body is the authority for
those, and there is no deadline by which they must be converted. `code` only
ever comes from the body, since `data` doesn't carry it.
THE BODY IS THE AUTHORITY; `data` is a mirror derived from it. Every writer
keeps them in step — the snippet service composes the mirror from the field
set it just merged, `update_note` recomposes it when a body reaches the
generic door (#3128), and `backfill_snippet_data` filled the pre-0070 rows
at startup. The fallback below is therefore a belt to that braces, not a
second source of truth: it is what a row looks like before the backfill has
run, and it must keep agreeing with the mirror.
(This docstring used to say those rows were "never backfilled". That was
true when 0070 landed and stopped being true when the backfill shipped; it
is corrected here because the sentence read as licence for a stale mirror,
which is exactly the bug #3128 found.)
`code` only ever comes from the body, since `data` doesn't carry it.
"""
parsed = parse_snippet_fields(note.title, note.body, note.tags)
stored = getattr(note, "data", None)