feat(notes): a note can carry its own check — verify_with, expires_when, verified_at (#3165, milestone 317 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped

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.
This commit is contained in:
2026-08-28 14:54:07 -04:00
parent 454c617ca0
commit 2065781302
4 changed files with 232 additions and 7 deletions
+81 -6
View File
@@ -16,12 +16,87 @@ import pytest
from scribe.services import backup
def test_backup_version_is_v8():
"""v7 added code_shapes (#2787), v8 its history (#2793). The bump is the
point of the test — a payload section added without moving the version
produces backups that are structurally different and indistinguishable
by inspection."""
assert backup.BACKUP_VERSION == 10
def test_backup_version_is_current():
"""The bump is the point of the test — a payload section added without
moving the version produces backups that are structurally different and
indistinguishable by inspection.
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 11
def _exportable_note(**over):
"""A Note-shaped stand-in for the pure row helper. SimpleNamespace, not a
MagicMock: `_note_rows` calls .isoformat() on the timestamps, and a mock
would happily return another mock instead of failing."""
base = dict(
id=1, user_id=7, title="t", body="b", tags=["x"], parent_id=None,
project_id=None, milestone_id=None, status=None, priority=None,
due_date=None, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
verify_with=None, expires_when=None, verified_at=None,
)
base.update(over)
return SimpleNamespace(**base)
def test_note_rows_carry_the_verification_trio():
"""Operator judgment — somebody checked this fact, and this is when —
which nothing downstream can recompute (milestone 317)."""
[row] = backup._note_rows([_exportable_note(
verify_with="curl the AMO docs",
expires_when="AMO starts allowing re-signing",
verified_at=datetime(2026, 8, 28, tzinfo=timezone.utc),
)])
assert row["verify_with"] == "curl the AMO docs"
assert row["expires_when"] == "AMO starts allowing re-signing"
assert row["verified_at"] == "2026-08-28T00:00:00+00:00"
def test_a_never_checked_note_exports_a_null_stamp_and_restores_as_one():
"""The round trip that matters. NULL `verified_at` means nobody has ever
looked, and it is what sorts FIRST in the sweep. Restoring it as now() —
which is what `_dt` would do — silently converts the sweep's top result
into its bottom one."""
[row] = backup._note_rows([_exportable_note(verify_with="check the runner shell")])
assert row["verified_at"] is None
assert backup._dt_or_none(row["verified_at"]) is None
# ...and the helper that must NOT be used here, for contrast.
assert backup._dt(row["verified_at"]) is not None
def test_the_note_section_gap_is_pinned_rather_than_silent():
"""#3182. `_note_rows` carries 16 of the `notes` table's 26 columns, and the
absences are not harmless: without `note_type` every snippet and process
restores as a plain note, and without `task_kind` every issue and spike
restores as `work`.
The table-coverage guard cannot see this — it asserts that every TABLE is
backed up or declared excluded, and nothing checks COLUMNS, which is how
these went missing quietly.
This test exists to make the gap loud and to make fixing it visible: when
#3182 lands, this list shrinks, and a reviewer sees exactly which fields
started travelling. It is not an endorsement of the omissions.
"""
from scribe.models.note import Note
carried = set(backup._note_rows([_exportable_note()])[0])
missing = {c.name for c in Note.__table__.columns} - carried
assert missing == {
# Deliberate: trashed rows are not exported.
"deleted_at",
# NOT deliberate — the #3182 gap, in the order they hurt.
"note_type", # snippets and processes flatten into notes
"task_kind", # issues and spikes flatten into work
"arose_from_id", # every issue -> origin edge is dropped
"recurrence_rule", "recurrence_next_spawn_at", # recurring tasks stop
"started_at", "completed_at", # lifecycle history
"description",
"data", # self-heals: backfill_snippet_data rebuilds it
}
def test_not_included_lists_the_known_gaps():