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
+35 -1
View File
@@ -50,8 +50,12 @@ logger = logging.getLogger(__name__)
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
# v11 (2026-08) added the note verification trio — notes.verify_with /
# expires_when / verified_at (milestone 317). NOT an audit of the notes
# section: it carries 16 of the 26 `notes` columns, and #3182 tracks the nine that
# have been missing since long before this.
# Bump when the serialized schema changes.
BACKUP_VERSION = 10
BACKUP_VERSION = 11
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -291,6 +295,19 @@ def _milestone_rows(rows) -> list[dict]:
def _note_rows(rows) -> list[dict]:
# INCOMPLETE, and knowingly so — see #3182. This carries 16 of the
# `notes` table's 26 columns. `note_type`, `task_kind`, `arose_from_id`, `data`,
# `description`, `recurrence_rule`, `recurrence_next_spawn_at`,
# `started_at` and `completed_at` are all absent, so a restore flattens
# every snippet and process into a plain note and every issue and spike
# into `work`. That predates the verification trio below and is tracked
# separately rather than fixed in passing: `arose_from_id` points at
# another note and needs the same second pass `parent_id` gets, which is
# a change with its own trap and deserves its own tests.
#
# The table-coverage guard cannot see this. It asserts that every TABLE in
# Base.metadata is either backed up or declared not-included; nothing
# checks columns, which is exactly how nine of them went missing quietly.
return [
{
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
@@ -300,6 +317,12 @@ def _note_rows(rows) -> list[dict]:
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
# The verification trio (milestone 317, migration 0092). These
# travel because they are operator judgment — "somebody checked
# this fact, and this is when" — which nothing can recompute.
"verify_with": n.verify_with,
"expires_when": n.expires_when,
"verified_at": n.verified_at.isoformat() if n.verified_at else None,
}
for n in rows
]
@@ -752,6 +775,13 @@ async def _restore_v1(data: dict) -> dict:
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
verify_with=n_data.get("verify_with"),
expires_when=n_data.get("expires_when"),
# _dt_or_none, NOT _dt: an absent stamp must stay absent. _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.
verified_at=_dt_or_none(n_data.get("verified_at")),
)
session.add(note)
await session.flush()
@@ -888,6 +918,10 @@ async def _restore_v2(data: dict) -> dict:
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
verify_with=n_data.get("verify_with"),
expires_when=n_data.get("expires_when"),
# _dt_or_none — see the note on the other restore path.
verified_at=_dt_or_none(n_data.get("verified_at")),
)
session.add(note)
await session.flush()