feat(supersession): declare it — supersedes on both write paths
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Skipped

Step 2 of #278. Records and reads the claim; the demotion that makes it matter
is step 3.

`services/supersession.py` with set/get on both directions, following the
set_record_systems shape since this is the same kind of mutable M2M at the
tool/route layer rather than inside notes_svc.

## Both directions are exposed, and only one is obvious

`supersedes` is what the author claimed. `superseded_by` is what a READER needs
and what the note itself cannot know — a stale record handed over with no
marker gets acted on confidently, which is worse than never surfacing it. So
get_note carries it, says so in its docstring, and adds a plain-language line
telling the reader to open the newer note first.

Both are OMITTED when empty rather than serialised as empty lists. A field that
always says nothing trains readers to skip fields — the lesson consolidated_at
cost, removed in the previous commit.

## Refuse vs drop, which is the one real judgement here

Dropped silently: a target that doesn't exist, is trashed, is the note itself,
or would close a cycle. Each is a claim with no subject or no meaning; none is
something the caller can act on.

REFUSED with PermissionError: a target the caller can read but not write.
That is the single case where the caller could believe they succeeded and be
wrong in a way that matters — demoting someone else's record out of their
retrieval is damage invisible from the outside, with no symptom for the owner
to trace. Rule #47, and PermissionError because services/snippets.py already
uses it for read-but-not-write with both surfaces catching it.

The PATCH/PUT routes scope by the CALLER, not owner_uid: an editor-share holder
may edit the note and must not thereby inherit the owner's write access to
whatever they name as superseded.

## Cycles

A ring claims every member is obsolete. Under flat demotion that demotes them
all equally, so the set drops out of ranked retrieval together with nothing in
the data saying why. Refused by walking the existing graph from the proposed
target — iteratively with a visited set, because the graph is user-supplied and
a deep chain must not become a stack overflow on a write path. The visited set
also makes the walk terminate on a ring that already exists, which is pinned by
its own test rather than trusted.

Both surfaces (#33), the instruction surface per #119 — framed as the third
answer beside update-instead and force=true: not everything resembling an
existing record should be folded into it, and not everything distinct should
compete with it forever.

Refs #278
This commit is contained in:
2026-08-07 22:38:09 -04:00
parent 5dcb738ce8
commit 8d9e96cc6d
5 changed files with 440 additions and 2 deletions
+176
View File
@@ -0,0 +1,176 @@
"""Which records have been overtaken by which — the claim, not the ranking.
Step 2 of milestone #278. This module only records and reads the relation; the
demotion that makes it matter lives in the retrieval layer.
WHY THE CLAIM POINTS FORWARD
The note being written declares what it supersedes. The older record cannot
know it has been overtaken — asking it to record its own obsolescence is asking
it to predict the future. So the party with the knowledge makes the claim, and
"has this been superseded?" is derived by looking at the far end.
WHAT IT MEANS
A claim, never a proof. It demotes a record in ranked retrieval; it does not
assert the older record was wrong and it never hides it. A note that accurately
described how something worked in June is still accurate about June.
Partial and many-to-many by nature: one note may supersede parts of several
others, and be overtaken piecemeal by several later ones.
"""
from __future__ import annotations
import logging
from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.note_supersession import NoteSupersession
from scribe.services import access
logger = logging.getLogger(__name__)
async def _closes_a_cycle(session, superseder_id: int, superseded_id: int) -> bool:
"""True if `superseder -> superseded` would complete a loop.
Walks the existing graph from `superseded_id` following superseder→superseded
edges. If the walk reaches `superseder_id`, the new edge closes a cycle.
Why refuse rather than tolerate: a cycle claims every member is obsolete, and
under FLAT demotion (see the milestone) that demotes all of them equally —
so a set of records that supersede each other in a ring would vanish from
ranked retrieval together, which is the opposite of the intent. Nothing about
the data would say why.
Iterative with a visited set, not recursion: the graph is user-supplied and
a deep chain must not become a stack overflow in a write path.
"""
seen: set[int] = set()
frontier = [superseded_id]
while frontier:
current = frontier.pop()
if current == superseder_id:
return True
if current in seen:
continue
seen.add(current)
rows = (await session.execute(
select(NoteSupersession.superseded_id)
.where(NoteSupersession.superseder_id == current)
)).scalars().all()
frontier.extend(int(r) for r in rows)
return False
async def set_supersedes(
user_id: int, note_id: int, superseded_ids: list[int]
) -> list[int] | None:
"""Replace what `note_id` claims to supersede (set semantics).
Returns the resulting list, or None if the caller cannot write the note
making the claim.
WHAT IS SILENTLY DROPPED, and why each is a drop rather than an error:
- ids that don't exist or are trashed — the claim has no subject
- the note's own id — meaningless, and the DB CHECK would refuse it anyway
- an id that would close a cycle — see _closes_a_cycle
WHAT IS REFUSED OUTRIGHT: a target the caller cannot WRITE. That is not a
silent drop, because it is the one case where the caller might reasonably
believe they succeeded and be wrong in a way that matters — demoting someone
else's record out of their retrieval is damage you cannot see from the
outside. Rule #47.
"""
if not await access.can_write_note(user_id, note_id):
return None
async with async_session() as session:
wanted: list[int] = []
for target in dict.fromkeys(superseded_ids): # de-dup, keep order
target = int(target)
if target == note_id:
continue
note = await session.get(Note, target)
if note is None or note.deleted_at is not None:
continue
if not await access.can_write_note(user_id, target):
raise PermissionError(
f"note {target} is not yours to supersede — you need write "
f"access to it, not just read. Superseding demotes a record "
f"in its owner's retrieval too."
)
if await _closes_a_cycle(session, note_id, target):
continue
wanted.append(target)
existing = set((await session.execute(
select(NoteSupersession.superseded_id)
.where(NoteSupersession.superseder_id == note_id)
)).scalars().all())
wanted_set = set(wanted)
to_remove = existing - wanted_set
if to_remove:
await session.execute(
delete(NoteSupersession).where(
NoteSupersession.superseder_id == note_id,
NoteSupersession.superseded_id.in_(to_remove),
)
)
for target in wanted:
if target not in existing:
session.add(
NoteSupersession(superseder_id=note_id, superseded_id=target)
)
await session.commit()
return wanted
async def get_supersedes(user_id: int, note_id: int) -> list[int]:
"""Ids this note claims to supersede. Empty if the caller can't read it."""
if not await access.can_read_note(user_id, note_id):
return []
async with async_session() as session:
return [int(i) for i in (await session.execute(
select(NoteSupersession.superseded_id)
.where(NoteSupersession.superseder_id == note_id)
.order_by(NoteSupersession.superseded_id)
)).scalars().all()]
async def get_superseded_by(user_id: int, note_id: int) -> list[int]:
"""Ids claiming to supersede this note.
The direction that matters to a READER, and the one the note itself cannot
know. An agent handed a stale record with no marker acts on it confidently;
that is worse than never surfacing it at all.
"""
if not await access.can_read_note(user_id, note_id):
return []
async with async_session() as session:
return [int(i) for i in (await session.execute(
select(NoteSupersession.superseder_id)
.where(NoteSupersession.superseded_id == note_id)
.order_by(NoteSupersession.superseder_id)
)).scalars().all()]
async def superseded_ids(note_ids: list[int]) -> set[int]:
"""Of `note_ids`, which have been superseded by anything. One query.
Deliberately NOT ACL-scoped: this feeds ranking over a candidate set the
caller has already been authorised to see, and re-checking per candidate
would be a per-result round trip on a hot path. Callers must pass an
already-scoped set — which is why this takes ids rather than a user.
"""
if not note_ids:
return set()
async with async_session() as session:
rows = (await session.execute(
select(NoteSupersession.superseded_id)
.where(NoteSupersession.superseded_id.in_(note_ids))
)).scalars().all()
return {int(r) for r in rows}