verify_with / expires_when / verified_at on a note, writable through both doors, with a sweep that asks which notes assert a fact nobody has confirmed. Ordered verified_at ASC NULLS FIRST — never-checked outranks checked-long-ago, because a claim nobody has ever confirmed is weaker than one confirmed a while back.
The discipline is the feature's precondition, not decoration: a field described only as "how to verify this" gets filled in on every note within a week, and then the sweep returns the whole corpus and means nothing. So the write surfaces say when a note earns a check, and a guard keeps them saying it.
Step 6 measured the result rather than assuming it: 6–11% of notes are constraints, which is the number that makes the sweep worth having.
Milestone 323 — a rule keeps what it used to say
note_versions has existed for a long time. A rule — which binds behaviour on every session that loads it — had nothing: an edit destroyed the previous wording with no record anywhere. Rescoping rule 79 meant hand-copying the superseded statement into a task log to keep it.
Schema + backup (v13). user_id is the ACTOR and SET NULL, so history outlives the account that wrote it — and the restore deliberately diverges from its NoteVersion sibling accordingly, pinned by an integration round trip.
Write path.update_rule snapshots before the field loop, so the snapshot holds the old verify_with — the check in force when that wording was written. Three guards from the sibling are deliberately absent (no interval gate, no MAX_VERSIONS, no pruning); all are autosave defences that rules have no use for, and their absence is now tested rather than merely commented.
Read path. Service, REST and an MCP rule_history tool, scoped through the rule rather than the version's user_id — that column is the actor, and scoping by it would leak in one direction and hide in the other.
UI. The history is in the rule slide-over, showing when and which fields moved, badging an edit that reset the verification stamp.
Staleness signal. The rules payload carries a marker; the write-path hook hands it back; a session is told when its rules have moved under it. The count is part of the marker because a deleted rule moves no timestamp — and that is the one change that takes an instruction out of force.
Fixes carried along
#3182 — a restore was flattening the record vocabulary: nine columns missing from notes alone meant every snippet and process restored as a plain note, every issue and spike as work, and every provenance edge dropped. A column guard now makes forgetting inexpressible.
#3141 — text on a tint of its own hue failed AA app-wide; check_design_tokens.py gates it now.
#3273 — records are cited by id and title across the skill and the tool responses, because an id alone is a lookup the reader has to perform.
Known limitation, stated in the code
The staleness marker catches another session's edit and a session misremembering a rule — it is blind to compaction, which is the most common case, because the marker was in context too and went with the rules. The SessionStart nudge remains that case's only mechanism and must not be softened on the strength of this. A test asserts both modules still explain that.
Not yet done
#3244's live acceptance is deploy-gated: edit a rule in one place, confirm a session elsewhere is told. Tests do not substitute for it.
23 commits. CI green on `7985f8c` (run 4949).
## Milestone 317 — a note can carry its own check
`verify_with` / `expires_when` / `verified_at` on a note, writable through both doors, with a sweep that asks *which notes assert a fact nobody has confirmed*. Ordered `verified_at ASC NULLS FIRST` — never-checked outranks checked-long-ago, because a claim nobody has ever confirmed is weaker than one confirmed a while back.
The discipline is the feature's precondition, not decoration: a field described only as "how to verify this" gets filled in on every note within a week, and then the sweep returns the whole corpus and means nothing. So the write surfaces say when a note *earns* a check, and a guard keeps them saying it.
Step 6 measured the result rather than assuming it: **6–11% of notes are constraints**, which is the number that makes the sweep worth having.
## Milestone 323 — a rule keeps what it used to say
`note_versions` has existed for a long time. A **rule** — which binds behaviour on every session that loads it — had nothing: an edit destroyed the previous wording with no record anywhere. Rescoping rule 79 meant hand-copying the superseded statement into a task log to keep it.
- **Schema + backup** (v13). `user_id` is the ACTOR and `SET NULL`, so history outlives the account that wrote it — and the restore deliberately diverges from its NoteVersion sibling accordingly, pinned by an integration round trip.
- **Write path.** `update_rule` snapshots *before* the field loop, so the snapshot holds the old `verify_with` — the check in force when that wording was written. Three guards from the sibling are deliberately absent (no interval gate, no `MAX_VERSIONS`, no pruning); all are autosave defences that rules have no use for, and their absence is now tested rather than merely commented.
- **Read path.** Service, REST and an MCP `rule_history` tool, scoped through the **rule** rather than the version's `user_id` — that column is the actor, and scoping by it would leak in one direction and hide in the other.
- **UI.** The history is in the rule slide-over, showing when and *which fields moved*, badging an edit that reset the verification stamp.
- **Staleness signal.** The rules payload carries a marker; the write-path hook hands it back; a session is told when its rules have moved under it. The count is part of the marker because a deleted rule moves no timestamp — and that is the one change that takes an instruction *out* of force.
## Fixes carried along
- **#3182** — a restore was flattening the record vocabulary: nine columns missing from `notes` alone meant every snippet and process restored as a plain note, every issue and spike as `work`, and every provenance edge dropped. A column guard now makes forgetting inexpressible.
- **#3141** — text on a tint of its own hue failed AA app-wide; `check_design_tokens.py` gates it now.
- **#3273** — records are cited by id *and* title across the skill and the tool responses, because an id alone is a lookup the reader has to perform.
## Known limitation, stated in the code
The staleness marker catches another session's edit and a session misremembering a rule — it is **blind to compaction**, which is the most common case, because the marker was in context too and went with the rules. The SessionStart nudge remains that case's only mechanism and must not be softened on the strength of this. A test asserts both modules still explain that.
## Not yet done
#3244's live acceptance is deploy-gated: edit a rule in one place, confirm a session elsewhere is told. Tests do not substitute for it.
The badge fix (#3132) exposed the same defect everywhere: 48 rules painting a
token as TEXT on an inline color-mix tint of that same token. Worst raw
measurements, across every tint strength in use, both modes, over
page/raised/hover:
accent 1.53:1 · success 1.67:1 · text-tertiary 2.15:1
warning 2.32:1 · error 2.36:1 against AA's 4.5
THE DEFECT IS IN THE HOUSE, NOT IN SCRIBE. The semantic hues are shared
family-wide, and the accent case was measured against every app's real
accent, not assumed from Scribe's: Minstrel 1.81, Forge 1.87, Steward 1.65,
Roundtable 3.01 — all failing. So the six -fg tokens are recorded on
FabledSword (design system 1), where their parents live, rather than copied
into each app.
45% toward --fs-text-primary clears AA for ALL FIVE accents (4.56-5.00), so
this is one house token rather than five overrides, and it keeps deriving
from --fs-accent — an app that overrides its accent still gets a legible
tinted-text colour in its own colour, the same mechanism as
--fs-accent-soft. The tokens are additive: a sibling app is unaffected until
it regenerates its own stylesheet.
One token is honestly redundant. --fs-text-secondary already passes at
4.82:1, and --fs-text-secondary-fg barely moves it. It exists so the rule
has NO exceptions, because the alternative is a permanent allow-list entry
for the one case that happens to pass — and a guard with an invisible
exception is a guard that erodes.
46 substitutions across 18 files, each rewriting only the `color:` inside a
block that tints its own background.
THE CHECK NOW GATES BOTH SPELLINGS. It previously reported the inline form,
because a gate nobody can satisfy on the day it lands gets switched off.
Both are clean, so both fail the build now.
And the check had a false-positive bug worth naming: its `color\s*:` regex
matched the tail of `border-color`, `border-left-color` and `outline-color`,
so it flagged seven rules that were already correct. A border is a non-text
graphic with a 3:1 floor, not text at 4.5. A check that cries wolf on
correct code is one that gets muted, so that mattered more than the noise.
Verified by construction, not by passing: reintroduced each defect form
(exit 1 each), and confirmed a legitimate border-only rule still exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Spike #3128's fourth question was whether a "when does a record type earn its
own table" rule was worth writing down. It turned out to already exist, in
full, in the RuleEmbedding docstring — the only written statement of a rule
Scribe applies to every record type, sitting where nobody would look for it.
Promoted to note 3163, with the three grounds (scoping different in kind,
machine-written at volume, edge-or-event-not-document), the worked cases
across the whole schema, and the bill: what `rules` had to re-import after
leaving `notes`, including the two cells it left empty on purpose.
The docstring stays put — it is where the decision was made — and now points
at the note.
The sibling of migration 0090, one table over. Same distinction: a NORM is a
decision with no truth value; a CONSTRAINT asserts a fact about someone
else's software and goes false with nobody watching. Notes hold far more
constraints than rules do and hold them longer — a cross-project reference
asserting what a signing service does on a duplicate upload is believed by
every project that reads it, and nothing in the record says when anyone last
looked. note_supersessions only fires once a human has already believed it.
Three nullable columns, no backfill, no index. The index margin is thinner
than 0090's — thousands of note rows against hundreds of rules — so the
comment says to decide it in step 3 against a real query plan rather than
guessing here.
The columns land on every row in `notes`, but only non-task, non-snippet
records will be OFFERED them (gated at the service in step 2): a task's decay
is its status, and a snippet already carries a richer location-aware verdict
in data.verification. A schema-level gate would have meant a CHECK across
three columns to say what the write path says in two lines.
Backup carries the trio (v11), with `verified_at` restored through
_dt_or_none — _dt substitutes now(), which would restore every never-checked
note as checked at the moment of the restore, inverting the one signal the
sweep reads.
Found while doing that, NOT fixed here, and now pinned by a test: `_note_rows`
carries 16 of the `notes` table's 26 columns. note_type, task_kind,
arose_from_id, the recurrence pair, the lifecycle stamps, description and data
have all been missing for a long time, so a restore flattens every snippet and
process into a plain note and every issue and spike into `work`. The coverage
guard cannot see it — it checks TABLES, not columns, which is #2293's failure
mode one level down. #3182 tracks it; arose_from_id needs the second
id-remapping pass parent_id gets, which is why it is not a drive-by fix.
The pin test caught its own inaccuracy on the first run, which is what it is
for. `deleted_batch_id` comes from SoftDeleteMixin alongside `deleted_at` and
is excluded for the same reason: trashed rows are not exported, so neither is
the batch id that groups them for restore(). The nine-field gap #3182 tracks
is unchanged.
`_note_rows` carried 16 of the `notes` table's 27 columns. A backup -> restore
cycle reported success and handed back a corpus with every snippet and process
flattened into a plain note, every issue and spike into `work`, every
provenance edge gone, and recurring tasks no longer recurring. The record-type
and kind vocabulary is what #3128 and milestone 312 were about, and a restore
erased it.
Two more found by auditing every row helper rather than only the one being
edited: `_milestone_rows` dropped `body` — a milestone IS the plan (0066), so
every plan restored as a title with no reasoning behind it — and
`_repo_binding_rows` dropped `ref`, the branch a ledger follows (#2873), so a
restored binding silently accounts for a different tree.
`arose_from_id` is deferred to a second pass beside `parent_id`, never written
in the constructor: it is an id in the SOURCE database, so copying it through
lands the edge on whatever record happens to hold that number here. An edge
whose target did not survive stays NULL rather than being guessed at. This is
the trap that kept the fix out of milestone 317 step 1.
THE STRUCTURAL HALF. The coverage guard from #2293 checks TABLES against
Base.metadata; nothing checked COLUMNS, which is how nine went missing from a
table that had been "covered" for years — added to the model and the migration,
both of which fail loudly, and never to the serialiser, which fails silently.
`_COLUMN_EXCLUSIONS` now declares, per table, every column deliberately not
exported and why, and a parameterised guard walks all 23 helpers and asserts
the two agree. Forgetting is no longer expressible.
Reconciling all 23 turned up one more deliberate exclusion worth naming: the
`code_shapes` proposal columns are the machine's standing suggestion, cleared
by judgment and recomputed by every refresh, so carrying them would restore
stale guesses over a tree the proposer has not seen.
Tests: the round trip drives the REAL restore_full_backup against Postgres,
not a reimplementation of its loop — a test that re-derives the remap it is
checking would agree with whatever the product does, including nothing.
Backup v12.
_BACKED_UP carries REAL table names — project_rulebook_subscriptions,
project_rule_suppressions, project_topic_suppressions,
project_rulebook_exclusions — not the shorter keys the payload uses for the
same sections. The registry-coverage assertion used the payload spelling and
reported four tables unguarded.
The integration round trip passed on this run, which is the half that matters:
the real restore_full_backup remaps arose_from_id onto the restored origin.
The rules path's three lessons (#3096), inherited:
EMPTY MEANS NULL. The sweep's whole signal is `verify_with IS NULL` = "this is
a decision, there is nothing to go and check". A "" that is not NULL makes a
norm look like a constraint nobody has verified — and never-checked sorts
FIRST, so it would sit at the top of the sweep forever.
CLEARING IS EXPLICIT. At the MCP door "" means "leave this alone", so an agent
updating a body does not wipe a check it was never asked about — which leaves
no value meaning "remove it". `clear` names the field, and naming it cannot
happen by accident. The REST door, where a cleared form input arrives as "",
reaches the same place through normalisation: two idioms, one outcome.
THE STAMP CERTIFIES A CHECK, NOT A RECORD. Rewrite or clear `verify_with` and
`verified_at` is dropped, so the note re-enters the sweep. A note wrongly
listed as due costs one look; a note wrongly vouched for costs exactly what
the sweep exists to catch. `verified_at` is also no longer settable through an
ordinary edit — a stamp says somebody performed THIS check, and minting one
from a write that ran no check is the one thing that would make the sweep lie.
And one this path adds: not every record may carry a check. A task's decay is
its status — a done issue records what happened rather than asserting
something that can go false — and a snippet already has verify_snippet, which
compares its recorded location and code against the repo and expires its own
verdict. Both are refused with a message naming the alternative, never dropped
silently (minted_kind's reasoning, #3129), and the gate lives at the service
so the two doors cannot come to disagree.
Written as an INVARIANT over the resulting record, not a filter on which
fields were passed. That is what catches the sideways route — a checked note
being turned into a task, a write that names no check at all and would sail
past any per-field gate.
The MCP docstrings carry the norm-vs-constraint test, because at that door the
docstring IS the contract and a field described only as "how to verify this"
gets filled in on every note. Step 5 does this properly across the instruction
surfaces; this is the minimum that stops the field being misused on arrival.
tests/helpers gains `drive_update_note` — the patch stack for driving
update_note, written twice before this and now once. The note-shaped fakes
gain the trio explicitly, for fake_note's own stated reason: an unset
attribute is a truthy MagicMock, and a truthy verify_with reads as a check
that is there.
Four tests predating the helper built their note with MagicMock(), which is
truthy on every attribute nobody set. update_note now reads verify_with, so
the stand-in claimed to carry a check and the milestone-317 guard refused the
write.
That is note 2109 exactly, and the reason fake_note exists: a stand-in has to
be able to say NO. The product behaviour is right — a real column is None or a
string, so this cannot happen outside a test.
Observation, not changed here: fake_note sets is_task=False but leaves
`status` unset, so it too is a truthy mock on the column is_task is derived
FROM. Nothing depends on it today; worth making self-consistent when something
does.
The read half. `notes_due_for_verification` + `mark_note_verified` + the MCP
pair, ordered `verified_at ASC NULLS FIRST`: never-checked outranks
checked-long-ago, because a note nobody has ever confirmed is a claim with no
evidence behind it at all. Postgres sorts NULLs LAST on ASC by default, so
getting this wrong would not error — it would silently invert the one signal
the sweep exists to carry, which is why it has a test of its own.
A SIBLING of rules_due_for_verification, not a shared implementation, and this
milestone is a deliberate self-application of note 3163: the row could have
been shared, the QUERY could not. That sweep scopes by rulebook ownership XOR
project ownership because rules have no sharing ACL at all; a note scopes by
the note ACL — browse, not read, so a record shared one-to-one never arrives
in a passive surface unasked (decision 2094).
What genuinely IS common moved to services/verification.py: how a stamp reads,
how old it is, and the three states `last_verified` distinguishes — None ("a
decision, the question does not apply"), "never" ("a fact nobody has
confirmed"), a date. Rulebooks now imports it rather than defining it, so this
is a consolidation and not a third copy.
A failed check writes NOTHING, carried over from 312: there is no "verified
false" state, because a note whose check failed is not in a special condition
— it is WRONG, and recording the failure as a flag would let it sit there
being false with the sweep quietly satisfied that somebody had looked.
Two decisions worth naming:
The sweep does NOT filter to non-task, non-snippet records even though the
write path permits a check on nothing else. Such a row would be in an ILLEGAL
state and this is the one surface that could say so; hiding it to match the
invariant would make the sweep agree with a database it had stopped
describing.
A negative `older_than_days` raises instead of meaning "everything" — silently
answering a different question is the failure shape this guards.
`notes_due_for_verification` is classified read-only in server.py, spelled out
because its name matches none of the prefixes the completeness test derives
from. `rules_due_for_verification` is in the same position and is NOT listed,
so it fails closed for read keys today — filed as #3191 rather than fixed
here, since widening an auth boundary on a tool I did not write is the
operator's call.
select(Note) names every column, so searching the whole statement for
"notes.note_type" always finds the projection, and sql.index("notes.id") finds
the first column rather than the ORDER BY tiebreak. Both tests were asking the
wrong string.
The ordering test now asserts on the END of the statement, and the filter test
reads the WHERE clause — extracted by regex rather than split on a literal,
because the exact whitespace SQLAlchemy puts around WHERE is not something a
test should depend on.
The product is unchanged: the two assertions that mattered — NULLS FIRST
present, and no legal-carrier filter in the predicate — were both already
true.
Rule 27: no UI, no ship. Three surfaces.
THE EDITOR ASKS, but only where the answer can be saved: the fields appear
for a plain note and not for a task or a snippet, matching the service gate
from step 2 so the form never offers a write the save would reject. The
labels are phrased as the QUESTION rather than the field name — "how would
someone check this is still true?" and, underneath, "could this become false
without anyone editing it?". "Verify with" gets filled in on every note; the
question gets filled in on the few that can go stale. `expires_when` appears
only once a check exists, and asks for a state rather than a date in the
placeholder itself.
THE NOTE SHOWS ITS AGE beside the field — "checked 2026-08-28" or "never
checked", italic, and nothing at all when no check exists. No red/amber ramp,
matching RuleSweepPane: a colour scale would restate the sweep's ordering and
force an invented staleness threshold. "Never" is marked because it is
categorically different from a date, not a worse one.
THE SWEEP is a pane in the Knowledge view, not beside the rules sweep —
operator's call, taken over a unified "everything due" surface and over a
second pane under /rules. Notes stay where notes live. The cost, accepted
knowingly: no single screen shows every unconfirmed record. It REPLACES the
feed rather than filtering it, because a facet answers "show me this kind"
and this answers "show me what nobody has confirmed" — a question the type
chips cannot narrow without under-reporting.
Two REST routes for it, since step 3 built only the service and the MCP door.
Along the way: NoteEditorView spelled its write payload out at three call
sites (save, create, auto-save), so every new field had to be added three
times — which is how one of them ends up not carrying it. Now one `payload()`
and one `snapshot()`.
Known and filed, not fixed: NoteSweepPane copies ~12 scoped CSS rules from
RuleSweepPane (#3207). The clean extraction needs prefixed names, because
`.age`, `.row-title`, `.lede` and `.actions` all exist scoped in other
components and an unscoped global would leak into them — which means editing
the shipped rules sweep, blind, inside a step whose acceptance is the
operator looking at a different surface.
The step that decides whether steps 1-4 were worth building. `verify_with` is
a free-text field on the highest-volume record kind in the product; described
only as "how to verify this note" it gets filled in on every note within a
week, and at that point the sweep returns the whole corpus and means nothing.
The signal is not "has a check" — it is "has a check AND almost nothing else
does".
Rule 119 puts this in the app's own instruction surfaces, never in a Scribe
rule. So:
- the using-scribe skill gains the reflex, next to "state updates in place;
chronicles don't" — its other half, since supersession only fires once
somebody has read a note and disagreed, which is the case where it was
already believed;
- update_note's docstring now states the DEFAULT rather than only deferring
to create_note for the test. Found by the new guard on its first run;
- plugin 0.1.48.
THE _INSTRUCTIONS BUDGET, decided rather than skipped. The payload is ~1980 of
the client's ~2048-char cap, so everything in it competes for the last ~68
characters. The operator declined a line for this milestone: the map's own
closing sentence says each tool's description carries the full contract, and
the sweep is a curation act rather than a session-start reflex like
enter_project or list_always_on_rules. That reasoning is now a comment beside
the constant, with the accepted cost named — an agent that never opens
create_note's docstring never learns the field exists — so the question is not
re-litigated blind.
The guard pins STRUCTURE, never wording, for the disambiguator's reason (a
test that punishes rewriting is a test that gets deleted): each write surface
must still draw the norm-vs-constraint distinction, say the empty case is
normal, and name where NOT to reach for it — plus that the skill carries the
one-question form, because the docstrings only reach a caller who already
opened the tool.
`_doc` moves to tests/helpers as `tool_doc`; it had been written twice.
Step 6 measured the claim the whole milestone rests on — that the notes
corpus divides into norms and constraints, with constraints a minority worth
sweeping — against a stratified sample of the real thing. It holds: ~29% in
`reference`, ~8% general, ~0-5% in `decision`, 0% in `dev-log`; roughly 6-11%
of ~395 plain notes. Written up as note 3210. The step was allowed to return
"revert" and does not.
Two things the measurement found that the guidance did not say, now added to
both the skill and the create_note docstring:
A SHARPER TEST. Every note that earned a check was about somebody ELSE's
software — a signing service, a forge, a hub, an SDK, a model, a dependency
set. Not one was about the operator's own code. "Is the thing this note
describes yours to change?" is decidable from the title in nearly every case,
where the abstract form needs thought.
THREE FALSE CANDIDATES, one of them a live hazard. Resume pointers and
"current state" notes go stale faster than anything else in the corpus, which
is exactly why they tempt — but the cure is to update or delete them, not to
schedule a check, and a sweep full of pointers is a sweep nobody reads.
Measurements of our own system go false because we changed something and knew.
And a decision RESTING on someone else's behaviour is still a decision — the
check belongs on the note asserting the fact.
Also recorded, not fixed: the corpus already contains a note titled
"CONSTRAINT: software only — no DIY hardware", using the word for a
self-imposed scope limit — a NORM in this taxonomy, exactly backwards. Both
surfaces already lead with the question rather than the label, which is the
mitigation; note 3210 names the collision so it is not rediscovered.
The sibling notes already had. `note_versions` snapshots a note's every
meaningful edit; a RULE, which binds behaviour on every session that loads
it, had nothing — an edit destroyed the previous wording with no record
anywhere. Rescoping rule 79 meant hand-copying the superseded statement into
a task log to keep it (#3237). The more consequential record had the weaker
protection.
Schema and transport only. Nothing writes a version yet — that is step 2.
Three guards are deliberately NOT copied from note_versions, each defending
against autosave, which rules do not have: no pruning or MAX_VERSIONS, no
pin columns, no minimum interval. A rule is edited a handful of times in its
life, and capping invites losing the one edit somebody needed.
`user_id` is the ACTOR rather than the owner, and SET NULL rather than
CASCADE: deleting a user must not erase the history of the rules they
edited. The restore diverges from its NoteVersion sibling accordingly — an
unmappable user leaves the row with a null actor instead of dropping it,
which is the whole point of choosing SET NULL. The integration round trip
pins that, because nothing in the code says which of the two shapes is
intended and "make it match the sibling" would silently delete the record.
Backup goes to v13. Both export paths carry the table; the per-user one
scopes through the rule rather than the version's user_id, or it would carry
the versions this user wrote on someone else's rule and drop the ones
someone else wrote on theirs. The restore remaps rule_id through
rule_id_map — #3182's arose_from_id trap on a new table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_dispose_engine` is a usefixtures entry, so it sets up AFTER an autouse
fixture and tears down BEFORE it. The purge running after this file's
`yield` therefore opened a fresh pooled connection that the closing loop
immediately orphaned, and the next test to touch Postgres died on "Future
attached to a different loop" — two of this file's own tests and
test_run_maintenance_vacuums_real_tables, which shares nothing with it but
the engine.
The autouse fixture is setup-only now, matching its sibling in
test_integration_backup_note_roundtrip.py, and the cleanup moved into
`restored`, whose teardown runs while the engine is still live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`update_rule` now snapshots the rule's text before it writes. Rescoping rule
79 meant hand-copying the superseded statement into a task log to keep it
(#3237); the history is that, done by the write path instead of by somebody
remembering.
The snapshot is taken BEFORE the field loop, which is the one ordering that
matters. `update_rule` drops `verified_at` when `verify_with` changes, and
rewriting a check is exactly the edit whose history is worth most — a
snapshot taken afterwards would file the NEW check against the OLD wording.
Taking it up front also covers `clear`, which is a separate argument from the
field loop and is how a rule that stops being a constraint loses its check
entirely.
Session-bound rather than opening its own like note_versions.create_version:
the version and the edit that caused it commit together, so a failed update
cannot leave a history entry for an edit that never happened.
Two guards from the sibling are deliberately absent, and both are now pinned
by tests rather than only by comments — "make it consistent with
note_versions" is a plausible-sounding change that would silently start
dropping history:
- No MIN_VERSION_INTERVAL_SECONDS. That 300-second gate exists because note
autosave fires every 60. Every version here comes from a deliberate
update_rule, so three edits in one second are three edits.
- No MAX_VERSIONS and no pruning. A rule is edited a handful of times in its
life; a cap could only ever discard the one edit somebody went looking for.
Kept from the sibling: the identical-content skip. Both doors resend every
field, so without it a form saved twice would file an identical snapshot.
`order_index` is excluded from the snapshot fields for the same reason —
reordering a rulebook is not an edit to what any rule says.
No delete-time snapshot, against the task's original scope and on the
operator's call. A delete goes through trash_svc and is SOFT: the rule row
keeps its full text and restores untouched, so there is nothing for a
snapshot to preserve. Anything that survived a purge would be data the
operator explicitly asked to be gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration lane failed on a deadlock, not an assertion — 103 passed, and
the one error was in teardown: `DELETE FROM rulebooks` blocked against
another process holding a lock on a rule row.
`update_rule` fires a detached asyncio.create_task(upsert_rule_embedding(...))
that opens its OWN connection and UPDATEs the rule it just saved. The
teardown's rulebook delete cascade-locks that same row, and Postgres resolves
the cycle by killing one of them. The sibling test_integration_rule_verification
never hit this because it calls update_rule but never deletes its rulebook.
Cleanup moves to setup, which runs on a fresh loop after the previous test's
loop has closed and cancelled whatever it left in flight. That also keeps the
#3240 constraint intact: no database call after a yield.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`list_rule_versions` / `get_rule_version` in the rulebooks service, a pair of
REST routes beside the note-version ones, and an MCP `rule_history` tool.
The ACL-scoped reads live in services/rulebooks.py rather than in
services/rule_versions.py because rulebooks already imports rule_versions for
the write path and the reverse would be a cycle. It is also the honest split:
rule_versions owns what a version IS, rulebooks owns who may read one.
Scoping is through the RULE, never the version's user_id, and both directions
of that mistake are now pinned by tests. That column is the ACTOR — scoping
by it would hand someone the snapshots they personally wrote on a rule that
has since moved out of their reach, and would hide from the rule's owner
every edit anyone else made. `get_rule_version` takes the rule id as well as
the version id so the ownership check and the fetch agree about which rule is
in play; the test for that uses a second rule the caller genuinely owns,
because a nonexistent id would pass on the ownership check alone and prove
nothing.
An unreadable rule returns None, not an empty list. The two mean different
things — "not your rule" versus "never reworded" — and the MCP tool keeps
them apart: None raises, empty says so in band.
THE DIFF QUESTION, ANSWERED — and the task's premise was half wrong. It says
"notes have DiffView.vue and a diff endpoint already". The component exists
and is reusable as-is: it takes `DiffLine[]` and nothing note-shaped, so step
4 can render a rule diff with it unchanged. The ENDPOINT does not exist —
diffs are computed client-side by `computeDiff` in useAssist.ts. So no diff
route is needed here, and none was written.
For the MCP door the answer is different again: an agent has no client to
compute a diff, but it also does not need one. Each entry holds the text the
edit REPLACED, so "what did this say before the most recent change?" is the
first entry, and the text that change produced is the rule as it stands. The
docstring says so, and a test pins that sentence — read the other way round,
every diff comes out backwards.
No restore, per the task. Putting an old wording back goes through
update_rule, which snapshots what it replaces, so the undo stays visible like
any other edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rule 27: a history nobody can read is not shipped. `RuleHistoryPanel.vue`
sits below the fields in `RuleEditorSlideOver`, where a rule is read in full
— not on the list row, where a history entry point would compete with the
row's job.
REUSE, DECIDED FIELD BY FIELD RATHER THAN ALL AT ONCE.
DiffView.vue is reused unchanged: it takes DiffLine[] and nothing
note-shaped.
HistoryPanel.vue is NOT, and its props are the reason — noteId +
currentBody, a NoteVersion carrying tags and pin columns, a fetch of
/api/notes/…, a restore emit, pin/unpin buttons. Rules have no tags, no
pins, and deliberately no restore, and a rule's text is EIGHT fields rather
than one body, which changes the reader's question from "what changed" to
"which fields moved". Recorded here rather than forked silently, per #3207.
THE FORK THAT WAS ALREADY THERE. The LCS walk existed three times —
privately in useAssist.ts, and again inside HistoryPanel.vue and
VersionHistorySection.vue — character-identical apart from quote style,
because computeDiff was never exported. Rather than add a fourth copy, it
moves to utils/diff.ts and the three become imports; the extraction was
verified equivalent to all three before anything was deleted. DiffLine is
re-exported from useAssist so its existing importers are untouched.
WHAT A ROW SHOWS: when, and which fields moved. A version holds the text the
edit REPLACED, so the edit is the step from a row to the next NEWER state —
the row above it, or, for the newest row, the rule as it stands now.
Comparing against the row below would attribute every change to the wrong
edit. A field nobody has fetched yet reads as neither changed nor unchanged.
An edit that touched verify_with is badged "check reset", because that edit
silently cleared verified_at (milestone 312) and put the rule back at the
top of the staleness sweep — a moment visible nowhere else.
The badge is a 12% color-mix TINT, not solid `--fs-warning`.
`--fs-warning-fg` is defined in theme.css as "warning TEXT on a warning
tint", so painting it over the solid token is exactly the same-hue contrast
failure #3141 records. Every var() the component references resolves against
theme.css, checked before pushing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rules payload carries a marker; the write-path hook hands it back; the
server says which rules moved. Nothing is said when nothing moved.
THE COUNT IS NOT DECORATION. max(updated_at) alone cannot see a DELETED rule
— it moves no timestamp — and that is the single change that takes an
instruction OUT of force, which is the one a session most needs to hear
about. The marker is `<max updated_at>|<count>`, and a deletion is reported
through the count because there is no row left to name.
THE HOOK IS THE CARRIER because it already fires before a write, which is the
moment acting on a stale rule costs something. One comparison, no payload,
no extra round trip.
WHERE THE MARKER IS CAPTURED, and it could not be anywhere else: the
SessionStart hook, from /api/plugin/context. The model also receives one from
list_always_on_rules, but a hook cannot see an MCP tool's result — so the
value the write path compares has to be stored where a shell script can
reach it. Keyed by session id in the state dir the prior-art hook already
uses, so "changed since" means since THIS session loaded its rules.
NOT ON rules_payload, against the task's letter. Those are applicable_rules —
a different, subscription-derived set. One key name over two sets is how a
comparison starts reporting phantom changes, and the write path compares
against the always-on set.
WHAT IT CANNOT SEE is stated in both the service and the write-path arm as a
table, because a reader who finds an etag will assume it covers staleness
generally:
another session edits a rule mid-flight | caught
the session is misremembering a rule read hours ago | caught
compaction summarised the rules out of context | NOT caught
The third is the most common, and the marker is blind to it — the etag was in
context too and went with the rules. The SessionStart nudge is that case's
only mechanism and must not be softened because this shipped. A test asserts
both modules still explain that.
Instance-agnostic (rule 115): an install with no rules produces a stable
marker rather than an error, and "no rules" reads as a state rather than as a
change. An unreadable or absent marker reports nothing — a signal that cries
wolf is worse than none, because it trains a reader to skip the line that
will one day be true. The arm fails open like every other arm on this hook.
The delivery is tested through the real build_write_path_hint rather than the
helper alone: the feature IS a line arriving in a session, and the arithmetic
being right proves nothing about that.
Live acceptance is deploy-gated and not yet recorded on the task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught two things, and the first is the feature not working rather than a
test being wrong.
THE SIGNAL WAS GATED ON A COINCIDENCE. build_write_path_hint returns early
when no prior art, stamp, divergence or derive matched, and that guard sat
ABOVE the new arm — so a session whose rules had changed was told only if the
file it happened to be editing also matched something else. A staleness
signal that fires on that coincidence is not a staleness signal. The arm now
runs above the guard, collecting into its own list that `lines` is seeded
from, and the guard accounts for it.
The standing-rule arm (milestone 307) is deliberately LEFT below that guard,
and this is a finding rather than a fix: it has the same gating and probably
should not, but it runs a SEMANTIC search, so lifting it would put an
embedding query on every write in every session. That is a cost decision, not
a bug fix, and not this task's to make.
THE MARKER MUST NOT BREAK THE PAYLOAD IT DECORATES. rules_etag is computed on
the SessionStart path, where `max()` raising costs the whole context payload
— every rule title, the project, all of it — to save a hint. A row with no
usable timestamp is now skipped and a set with none degrades to a count-only
marker, which still catches a rule added or deleted and only loses edits.
That is the right way round to lose information. CI found it because
build_session_context's tests pass MagicMock rules and `max()` over those
raises TypeError.
Also: list_always_on_rules on an install with no always-on rulebooks returns
`rules_etag: "empty|0"`. Its exact-dict test is updated rather than loosened
— the key being present on an empty install is the behaviour, not noise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught the naming work reaching for Postgres from the unit lane, and the
connection error was the symptom of a real design fault rather than a test
gap: reading the title BEFORE the delete put a live query on the delete path,
so a lookup that failed would have stopped the delete happening.
That is a decoration breaking its payload — the same mistake just fixed in
rules_etag, made again two commits later. Every title lookup now fails open:
delete_task, delete_note, delete_milestone, delete_snippet and rule_history
lose the name, never the operation.
The five unit tests mock the lookup rather than reaching for a database, and
delete_note gains one asserting the delete still happens when the lookup
raises — the behaviour, not just the absence of a crash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
23 commits. CI green on
7985f8c(run 4949).Milestone 317 — a note can carry its own check
verify_with/expires_when/verified_aton a note, writable through both doors, with a sweep that asks which notes assert a fact nobody has confirmed. Orderedverified_at ASC NULLS FIRST— never-checked outranks checked-long-ago, because a claim nobody has ever confirmed is weaker than one confirmed a while back.The discipline is the feature's precondition, not decoration: a field described only as "how to verify this" gets filled in on every note within a week, and then the sweep returns the whole corpus and means nothing. So the write surfaces say when a note earns a check, and a guard keeps them saying it.
Step 6 measured the result rather than assuming it: 6–11% of notes are constraints, which is the number that makes the sweep worth having.
Milestone 323 — a rule keeps what it used to say
note_versionshas existed for a long time. A rule — which binds behaviour on every session that loads it — had nothing: an edit destroyed the previous wording with no record anywhere. Rescoping rule 79 meant hand-copying the superseded statement into a task log to keep it.user_idis the ACTOR andSET NULL, so history outlives the account that wrote it — and the restore deliberately diverges from its NoteVersion sibling accordingly, pinned by an integration round trip.update_rulesnapshots before the field loop, so the snapshot holds the oldverify_with— the check in force when that wording was written. Three guards from the sibling are deliberately absent (no interval gate, noMAX_VERSIONS, no pruning); all are autosave defences that rules have no use for, and their absence is now tested rather than merely commented.rule_historytool, scoped through the rule rather than the version'suser_id— that column is the actor, and scoping by it would leak in one direction and hide in the other.Fixes carried along
notesalone meant every snippet and process restored as a plain note, every issue and spike aswork, and every provenance edge dropped. A column guard now makes forgetting inexpressible.check_design_tokens.pygates it now.Known limitation, stated in the code
The staleness marker catches another session's edit and a session misremembering a rule — it is blind to compaction, which is the most common case, because the marker was in context too and went with the rules. The SessionStart nudge remains that case's only mechanism and must not be softened on the strength of this. A test asserts both modules still explain that.
Not yet done
#3244's live acceptance is deploy-gated: edit a rule in one place, confirm a session elsewhere is told. Tests do not substitute for it.
The read half. `notes_due_for_verification` + `mark_note_verified` + the MCP pair, ordered `verified_at ASC NULLS FIRST`: never-checked outranks checked-long-ago, because a note nobody has ever confirmed is a claim with no evidence behind it at all. Postgres sorts NULLs LAST on ASC by default, so getting this wrong would not error — it would silently invert the one signal the sweep exists to carry, which is why it has a test of its own. A SIBLING of rules_due_for_verification, not a shared implementation, and this milestone is a deliberate self-application of note 3163: the row could have been shared, the QUERY could not. That sweep scopes by rulebook ownership XOR project ownership because rules have no sharing ACL at all; a note scopes by the note ACL — browse, not read, so a record shared one-to-one never arrives in a passive surface unasked (decision 2094). What genuinely IS common moved to services/verification.py: how a stamp reads, how old it is, and the three states `last_verified` distinguishes — None ("a decision, the question does not apply"), "never" ("a fact nobody has confirmed"), a date. Rulebooks now imports it rather than defining it, so this is a consolidation and not a third copy. A failed check writes NOTHING, carried over from 312: there is no "verified false" state, because a note whose check failed is not in a special condition — it is WRONG, and recording the failure as a flag would let it sit there being false with the sweep quietly satisfied that somebody had looked. Two decisions worth naming: The sweep does NOT filter to non-task, non-snippet records even though the write path permits a check on nothing else. Such a row would be in an ILLEGAL state and this is the one surface that could say so; hiding it to match the invariant would make the sweep agree with a database it had stopped describing. A negative `older_than_days` raises instead of meaning "everything" — silently answering a different question is the failure shape this guards. `notes_due_for_verification` is classified read-only in server.py, spelled out because its name matches none of the prefixes the completeness test derives from. `rules_due_for_verification` is in the same position and is NOT listed, so it fails closed for read keys today — filed as #3191 rather than fixed here, since widening an auth boundary on a tool I did not write is the operator's call.select(Note) names every column, so searching the whole statement for "notes.note_type" always finds the projection, and sql.index("notes.id") finds the first column rather than the ORDER BY tiebreak. Both tests were asking the wrong string. The ordering test now asserts on the END of the statement, and the filter test reads the WHERE clause — extracted by regex rather than split on a literal, because the exact whitespace SQLAlchemy puts around WHERE is not something a test should depend on. The product is unchanged: the two assertions that mattered — NULLS FIRST present, and no legal-carrier filter in the predicate — were both already true.