feat(notes): the sweep — which notes assert a fact nobody has confirmed (#3166, milestone 317 step 3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
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.
This commit is contained in:
@@ -599,6 +599,154 @@ async def update_note(
|
||||
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
||||
|
||||
|
||||
# ── The sweep (milestone 317 step 3) ─────────────────────────────────────────
|
||||
#
|
||||
# A SIBLING of rulebooks.rules_due_for_verification, not a shared
|
||||
# implementation, and deliberately so (note 3163). The row could have been
|
||||
# shared; the QUERY cannot. That sweep scopes by rulebook ownership XOR project
|
||||
# ownership because rules have no sharing ACL at all — no rule_shares, no
|
||||
# can_read_rule. A note scopes by the note ACL, which is a different question
|
||||
# with a different answer. What IS common — how a stamp reads, how old it is —
|
||||
# lives in services/verification.py and is imported by both.
|
||||
|
||||
async def notes_due_for_verification(
|
||||
user_id: int,
|
||||
older_than_days: int = 0,
|
||||
project_id: int | None = None,
|
||||
never_only: bool = False,
|
||||
) -> list[Note]:
|
||||
"""Notes that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
THE QUERY THE COLUMNS EXIST FOR. `verify_with` and `expires_when` are
|
||||
storage; this is what turns them into something that gets acted on.
|
||||
Without it, note decay is caught only when a human reads the note and
|
||||
disagrees — which is the case where the note was already believed.
|
||||
|
||||
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 this is explicit — and getting it wrong would not error, it would
|
||||
silently invert the one signal the sweep exists to carry.
|
||||
|
||||
Notes with no `verify_with` never appear. Not an omission: they are
|
||||
decisions, there is nothing to go and check, and listing them would dilute
|
||||
the result until nobody reads it.
|
||||
|
||||
Scoped with `browsable_notes_clause`, NOT the read scope (decision note
|
||||
2094): a sweep is a passive surface, and a record shared one-to-one must
|
||||
not arrive in one unasked.
|
||||
|
||||
Deliberately NOT filtered to non-task, non-snippet records even though the
|
||||
write path (step 2) permits a check on nothing else. A row in that state
|
||||
would be a row in an ILLEGAL state, and this is the one surface that could
|
||||
tell somebody about it. Hiding it here to match the invariant would make
|
||||
the sweep agree with a database it had stopped describing.
|
||||
|
||||
Args:
|
||||
user_id: whose notes.
|
||||
older_than_days: only notes last verified longer ago than this.
|
||||
Never-checked notes always qualify — they are the most overdue
|
||||
thing there is. 0 = no age filter. Negative raises: it would mean
|
||||
"everything", which is a different question than the one asked,
|
||||
answered silently.
|
||||
project_id: narrow to one project. None = every project.
|
||||
never_only: only notes that have never been verified.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from scribe.services.access import browsable_notes_clause
|
||||
|
||||
if older_than_days < 0:
|
||||
raise ValueError(
|
||||
f"older_than_days must be >= 0, got {older_than_days}. A negative "
|
||||
f"window silently means 'everything', which is not what any caller "
|
||||
f"of a staleness sweep is asking."
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
# One statement, not a fetch-then-filter: the ordering below is the
|
||||
# database's, so it cannot disagree with itself across two halves.
|
||||
stmt = (
|
||||
select(Note)
|
||||
.where(
|
||||
browsable_notes_clause(user_id),
|
||||
Note.deleted_at.is_(None),
|
||||
Note.verify_with.is_not(None),
|
||||
)
|
||||
)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(Note.project_id == project_id)
|
||||
if never_only:
|
||||
stmt = stmt.where(Note.verified_at.is_(None))
|
||||
elif older_than_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||
stmt = stmt.where(
|
||||
or_(Note.verified_at.is_(None), Note.verified_at < cutoff)
|
||||
)
|
||||
stmt = stmt.order_by(Note.verified_at.asc().nullsfirst(), Note.id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def verification_row(note: Note) -> dict:
|
||||
"""One row of the sweep — the CHECK in full, unlike a listing.
|
||||
|
||||
The opposite call from a browse: here the caller is about to go and run the
|
||||
check, so the text they need IS the payload rather than the bloat.
|
||||
"""
|
||||
from scribe.services.verification import (
|
||||
days_since_verified,
|
||||
last_verified_label,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"project_id": note.project_id,
|
||||
"verify_with": note.verify_with or "",
|
||||
"expires_when": note.expires_when or "",
|
||||
"last_verified": last_verified_label(note),
|
||||
"days_since_verified": days_since_verified(note),
|
||||
}
|
||||
|
||||
|
||||
async def mark_note_verified(
|
||||
note_id: int, user_id: int, still_true: bool = True,
|
||||
) -> Note | None:
|
||||
"""Stamp a note as verified — or, when the check FAILED, refuse to.
|
||||
|
||||
The asymmetry is the design: passing writes a stamp, failing writes
|
||||
nothing. There is no "verified false" state, because a note whose check
|
||||
failed is not a note in a special condition — it is a note that is WRONG,
|
||||
and the honest resolutions are to correct it, supersede it, or find out
|
||||
why. Recording the failure as a flag would let it sit there being false
|
||||
with the sweep quietly satisfied that somebody had looked.
|
||||
|
||||
So a failed check leaves `verified_at` untouched and the note stays at the
|
||||
top of the sweep until someone actually deals with it.
|
||||
|
||||
Write access, not read (rules 47/78): stamping is a mutation, and an
|
||||
editor-share holder may make it while a viewer may not.
|
||||
|
||||
Returns None when the note is not found, not writable, or carries no
|
||||
`verify_with` — nothing to verify is a different answer from verified.
|
||||
"""
|
||||
from scribe.services.access import can_write_note
|
||||
|
||||
async with async_session() as session:
|
||||
note = (await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.deleted_at.is_(None))
|
||||
)).scalars().first()
|
||||
if note is None or not note.verify_with:
|
||||
return None
|
||||
if not await can_write_note(user_id, note_id):
|
||||
return None
|
||||
if still_true:
|
||||
note.verified_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||
async with async_session() as session:
|
||||
if q:
|
||||
|
||||
Reference in New Issue
Block a user