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
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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user