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
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:
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
@@ -55,12 +56,43 @@ async def list_notes(
|
||||
return {"notes": [n.to_dict() for n in rows], "total": total}
|
||||
|
||||
|
||||
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
"""Add both directions of the supersession relation to a note payload.
|
||||
|
||||
Both, because they answer different questions and only one of them 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 without that marker gets acted on confidently, which is worse than
|
||||
never surfacing it.
|
||||
|
||||
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
|
||||
permanently-empty lists. A field that always says nothing trains readers to
|
||||
skip fields, which is the lesson `consolidated_at` cost us (#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
|
||||
data["superseded_note"] = (
|
||||
"A later note claims to bring this up to date — see superseded_by. "
|
||||
"Read this as what was true when written, and check the newer one "
|
||||
"before acting on it."
|
||||
)
|
||||
|
||||
|
||||
async def get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Scribe note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
A note another user shared with you also carries `shared`, `owner` and
|
||||
`permission` — read it as their suggestion, not as settled practice you set.
|
||||
|
||||
IF THE RESULT CARRIES `superseded_by`, a later note claims to have brought
|
||||
this one up to date. It is still here and still readable — supersession
|
||||
demotes, it never hides — but read it as what was true when written, and
|
||||
open the newer note before acting on it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
||||
@@ -74,6 +106,7 @@ async def get_note(note_id: int) -> dict:
|
||||
# snippets would leave those permanently at zero pulls and make them look
|
||||
# like dead weight next to snippets that merely had a counter (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -83,6 +116,7 @@ async def create_note(
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
supersedes: list[int] | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new note in Scribe.
|
||||
@@ -94,6 +128,15 @@ async def create_note(
|
||||
project_id: Associate with a project (use 0 for no project / orphan note).
|
||||
system_ids: Ids of the project's Systems to associate this note with
|
||||
(e.g. research about a subsystem). See list_systems / create_system.
|
||||
supersedes: Ids of EARLIER notes this one replaces or brings up to date.
|
||||
Reach for it whenever you write something that overtakes what an
|
||||
older note recorded — a re-measurement, a decision that reverses an
|
||||
earlier one, a dev-log covering ground a previous one covered.
|
||||
The older note stays readable and keeps its place in search; it
|
||||
simply stops competing with this one for the same question, and
|
||||
arrives labelled when it does surface. This records a CLAIM, not a
|
||||
verdict: it never says the older note was wrong, only that it is no
|
||||
longer the current answer.
|
||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||
meaning-similar note already exists in the same project, creation is
|
||||
BLOCKED and the existing note's id is returned so you update it
|
||||
@@ -121,11 +164,19 @@ async def create_note(
|
||||
)
|
||||
if system_ids:
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
if supersedes:
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note.id, supersedes)
|
||||
except PermissionError as exc:
|
||||
# The note WAS created — surface the real reason rather than a
|
||||
# not-found, and leave the note rather than silently rolling it back.
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
await _attach_supersession(uid, note.id, data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -136,6 +187,7 @@ async def update_note(
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
supersedes: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
||||
|
||||
@@ -147,6 +199,9 @@ async def update_note(
|
||||
project_id: New project association. Omit (or pass 0) to leave unchanged.
|
||||
system_ids: Replace this note's System associations with these ids
|
||||
(set-semantics). None = leave unchanged; [] = clear all.
|
||||
supersedes: Replace the ids of earlier notes this one replaces
|
||||
(set-semantics). None = leave unchanged; [] = clear all. See
|
||||
create_note for when to reach for it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -163,11 +218,17 @@ async def update_note(
|
||||
raise ValueError(f"note {note_id} not found")
|
||||
if system_ids is not None:
|
||||
await systems_svc.set_record_systems(uid, note_id, system_ids)
|
||||
if supersedes is not None:
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note_id, supersedes)
|
||||
except PermissionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
|
||||
]
|
||||
await _attach_supersession(uid, note_id, data)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user