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
+45 -2
View File
@@ -22,7 +22,26 @@ from scribe.services.notes import (
update_note,
)
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services import supersession as supersession_svc
from scribe.services.note_usage import record_pulled
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Both directions of the supersession relation on a note payload.
Mirrors the MCP helper of the same name — the two surfaces must agree about
what a note's payload says, or the web UI and the agent would disagree about
whether a record is current.
Omitted when empty: a field that always says nothing trains readers to skip
fields, which is what `consolidated_at` cost (#2483).
"""
supersedes = await supersession_svc.get_supersedes(uid, note_id)
superseded_by = await supersession_svc.get_superseded_by(uid, note_id)
if supersedes:
data["supersedes"] = supersedes
if superseded_by:
data["superseded_by"] = superseded_by
from scribe.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__)
@@ -112,7 +131,19 @@ async def create_note_route():
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify(note.to_dict()), 201
# Same capability as the MCP create path (#33). Without it the web UI would
# be the surface on which a supersession claim silently cannot be made.
if data.get("supersedes"):
try:
await supersession_svc.set_supersedes(uid, note.id, data["supersedes"])
except PermissionError as exc:
# 403, not 400: the request is well-formed and the caller simply
# may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
out = note.to_dict()
await _attach_supersession(uid, note.id, out)
return jsonify(out), 201
@notes_bp.route("/tags", methods=["GET"])
@@ -186,6 +217,7 @@ async def get_note_route(note_id: int):
# injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await _attach_supersession(uid, note_id, data)
return jsonify(data)
@@ -224,7 +256,18 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
return jsonify(note.to_dict())
# Set-semantics, matching MCP and the PATCH route: present-and-empty
# clears, absent leaves alone. Scoped by the CALLER, not owner_uid — an
# editor-share holder may edit this note and must not thereby inherit the
# owner's write access to whatever they name as superseded (#47).
if "supersedes" in data:
try:
await supersession_svc.set_supersedes(uid, note_id, data["supersedes"] or [])
except PermissionError as exc:
return jsonify({"error": str(exc)}), 403
out = note.to_dict()
await _attach_supersession(uid, note_id, out)
return jsonify(out)
@notes_bp.route("/<int:note_id>", methods=["PATCH"])