rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Which records have been overtaken by which — the claim, not the ranking.
|
||||
|
||||
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 grimoire.models import async_session
|
||||
from grimoire.models.note import Note
|
||||
from grimoire.models.note_supersession import NoteSupersession
|
||||
from grimoire.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.
|
||||
"""
|
||||
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_relations(user_id: int, note_id: int) -> dict[str, list[int]]:
|
||||
"""Both directions for one note: what it supersedes, and what supersedes it.
|
||||
|
||||
ONE query and ONE ACL check, because this runs on every note read. Asking
|
||||
the two questions separately doubled the round trips on the hottest path in
|
||||
the product to save a two-line partition — the wrong trade, and one I made
|
||||
on the first attempt.
|
||||
|
||||
Returns {"supersedes": [...], "superseded_by": [...]}, both sorted. Empty
|
||||
lists when the caller cannot read the note.
|
||||
|
||||
`superseded_by` is 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, which is worse than never surfacing it at all.
|
||||
"""
|
||||
empty: dict[str, list[int]] = {"supersedes": [], "superseded_by": []}
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return empty
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(
|
||||
NoteSupersession.superseder_id, NoteSupersession.superseded_id
|
||||
).where(
|
||||
(NoteSupersession.superseder_id == note_id)
|
||||
| (NoteSupersession.superseded_id == note_id)
|
||||
)
|
||||
)).all()
|
||||
supersedes = sorted(
|
||||
int(old) for new, old in rows if int(new) == note_id
|
||||
)
|
||||
superseded_by = sorted(
|
||||
int(new) for new, old in rows if int(old) == note_id
|
||||
)
|
||||
return {"supersedes": supersedes, "superseded_by": superseded_by}
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
SUPERSEDED_HINT = (
|
||||
"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 attach_relations(user_id: int, note_id: int, data: dict, *, hint: bool = False) -> None:
|
||||
"""Add both directions of the supersession relation to a note payload.
|
||||
|
||||
ONE seam for the REST and MCP surfaces, which must agree about what a
|
||||
note's payload says — or the web UI and the agent would disagree about
|
||||
whether a record is current. Both directions, because they answer
|
||||
different questions 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 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. `hint=True` (the agent surface) also
|
||||
attaches `superseded_note`, the one-sentence reading instruction.
|
||||
"""
|
||||
rel = await get_relations(user_id, note_id)
|
||||
if rel["supersedes"]:
|
||||
data["supersedes"] = rel["supersedes"]
|
||||
if rel["superseded_by"]:
|
||||
data["superseded_by"] = rel["superseded_by"]
|
||||
if hint:
|
||||
data["superseded_note"] = SUPERSEDED_HINT
|
||||
Reference in New Issue
Block a user