fix(systems): a record can be filed under a System from whichever door wrote it (#4249)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 29s

A System tag is how `list_system_records` gathers an area's pile, so a
record that cannot be tagged is reachable by search and by nothing else.
`update_lesson` did not take `system_ids` while `create_lesson` did, which
made tagging available exactly once — at the moment of least information.
A lesson is usually written at the end of a piece of work, which is
precisely when that argument gets dropped, and after that the record could
never be filed at all.

Filling in the rest of the table found three more gaps, and the issue's own
generalisation was wrong. It read as "the REST door can do something the
MCP door cannot", on three instances in a row. In fact:

  * `create_preference` is the exact MIRROR of the lesson bug — update
    takes `system_ids`, create does not. The same capability missing from
    the opposite end of the same lifecycle.
  * `routes/notes.py` handled `system_ids` NOWHERE, while the MCP door
    handled both ends. That runs the opposite way round from the premise.
  * `create_process` / `update_process` took it at neither door, though a
    process is a note and has always been taggable in the data model.

The real pattern is that whichever door nobody exercised for a kind is the
one that never grew the parameter — which is a better statement of #4248
than the one recorded there, and is not something a reviewer reliably
notices, because each door is only ever read on its own.

Milestones are NOT a fifth gap. `RecordSystem.note_id` is a ForeignKey to
`notes.id` and milestones are their own table, so they cannot be tagged at
any door by construction. Pinned in the test so the next pass does not
re-open it.

So the guard is the point, not the four parameters. `update_lesson` alone
would have left the shape that produced it intact. The new test asserts the
TABLE — every kind taggable anywhere is taggable everywhere it is written —
and keys the registry-coverage check on the SIGNATURE rather than on a
grep, so a module that only names the argument in prose is not swept in and
no hand-kept skip list can go stale. A fifth kind fails there rather than
shipping half-wired, the same reasoning test_derived_mirror_generic_door.py
records for derived mirrors (#3734).

One inconsistency found and deliberately not changed here: for the same
operation `routes/tasks.py` scopes `set_record_systems` by the caller while
`routes/lessons.py` and `routes/snippets.py` scope it by the owner. The new
notes code follows tasks.py and says why in a comment (#47 — an editor-share
holder should tag from what they can see rather than inherit the owner's
reach). Recorded in #4249 rather than fixed as a drive-by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 16:02:16 -04:00
co-authored by Claude Opus 5
parent 42360f616c
commit e2e1ea3667
5 changed files with 258 additions and 4 deletions
+16
View File
@@ -229,6 +229,7 @@ async def update_lesson(
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
system_ids: list[int] | None = None,
) -> dict:
"""Update a lesson. Empty fields are left unchanged.
@@ -249,6 +250,15 @@ async def update_lesson(
learned_from: Replace the source ids. None leaves unchanged; pass the
FULL list, including the ones already there.
tags: Replace tags. None leaves unchanged.
system_ids: Replace the Systems this lesson is filed under. None
leaves unchanged; pass the FULL list, and `[]` to clear.
Here because `create_lesson` took it and this did not, so a lesson
written without one could never be filed afterwards — and the end
of a piece of work, when a lesson is usually written, is exactly
when that argument gets dropped (#4249). A System tag is how
`list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else.
"""
uid = current_user_id()
note = await lessons_svc.update_lesson(
@@ -261,6 +271,12 @@ async def update_lesson(
)
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
# `is not None` rather than truthiness, so `[]` CLEARS the associations.
# `set_record_systems` is replace-semantics; treating [] as "no change"
# would make the one call that unfiles a lesson silently do nothing.
if system_ids is not None:
await systems_svc.set_record_systems(uid, lesson_id, system_ids)
note = await lessons_svc.get_lesson(uid, lesson_id) or note
return _to_dict(note)
+17 -2
View File
@@ -11,6 +11,7 @@ from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_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
@@ -50,7 +51,8 @@ async def list_processes(
async def create_process(
title: str, body: str, tags: list[str] | None = None, force: bool = False,
title: str, body: str, tags: list[str] | None = None,
system_ids: list[int] | None = None, force: bool = False,
) -> dict:
"""Create a stored process (a reusable saved prompt).
@@ -73,6 +75,9 @@ async def create_process(
title: Process name, e.g. "Drift Audit" (required).
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
system_ids: Systems (subsystems/areas) to file this process under, so
an area-scoped read finds it. A process is a note, so it has always
been taggable in the data model; neither door offered it (#4249).
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar process already exists, creation is BLOCKED and the
existing one's id is returned so you update it instead. Set true
@@ -99,6 +104,8 @@ async def create_process(
note = await notes_svc.create_note(
uid, title=title.strip(), body=body, note_type="process", tags=tags,
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
return note.to_dict()
@@ -153,10 +160,14 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
async def update_process(process_id: int, title: str = "", body: str = "",
tags: list[str] | None = None) -> dict:
tags: list[str] | None = None,
system_ids: list[int] | None = None) -> dict:
"""Update a stored process. Only provided fields change — empty title/body
leave that field unchanged; pass tags to replace the tag set.
`system_ids` replaces the Systems this process is filed under: None leaves
them alone, a list (including `[]`) replaces them.
Editing another user's process requires an editor or admin share from them; a
read-only share is not enough and says so rather than claiming not-found.
"""
@@ -179,6 +190,10 @@ async def update_process(process_id: int, title: str = "", body: str = "",
fields["tags"] = tags
# As the owner — update_note is owner-scoped and the write is authorised above.
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
# Written as the OWNER, matching the update above: an editor-shared process
# keeps its owner's associations rather than sprouting a second set.
if system_ids is not None:
await systems_svc.set_record_systems(note.user_id, process_id, system_ids)
if updated is None:
raise ValueError(f"process {process_id} not found")
out = updated.to_dict()
+7 -2
View File
@@ -712,7 +712,8 @@ async def update_rule(
async def create_preference(
topic_id: int, title: str, statement: str, when_to_apply: str,
arose_from_id: int, why: str = "", how_to_apply: str = "",
order_index: int = 0, force: bool = False,
order_index: int = 0, system_ids: list[int] | None = None,
force: bool = False,
) -> dict:
"""Record how the operator wants work done. No approval loop — write it.
@@ -777,6 +778,10 @@ async def create_preference(
statement: How the operator wants it done, in their terms.
when_to_apply: The moment it applies. Required; see above.
arose_from_id: The task or note that taught this. Required; see above.
system_ids: Ids from list_canonical_systems — the global AREAS this
preference is about, which is what lets it reach a session working
in that area. `update_preference` took this and create did not, so
a preference could only be filed after the fact (#4249).
force: Bypass the near-duplicate gate. For a genuinely distinct
preference, not for one that is "mostly" different — a mostly
different preference is an update.
@@ -804,7 +809,7 @@ async def create_preference(
kind="preference", arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return await rulebooks_svc.rule_detail(uid, rule, None)
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
async def update_preference(
+17
View File
@@ -27,6 +27,7 @@ from scribe.services.notes import (
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services import dedup as dedup_svc
from scribe.services import supersession as supersession_svc
from scribe.services import systems as systems_svc
from scribe.services.note_usage import record_pulled
from scribe.services.note_versions import list_versions, get_version
@@ -130,6 +131,12 @@ async def create_note_route():
# 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
# #4249: this door could not file a note to a System at all, while the
# MCP door could — the reverse of the asymmetry the same issue records for
# lessons. The pattern is not that one door is richer; it is that the door
# nobody exercised for a kind is the one that never grew the parameter.
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = note.to_dict()
await supersession_svc.attach_relations(uid, note.id, out)
return jsonify(out), 201
@@ -289,6 +296,16 @@ async def update_note_route(note_id: int):
await supersession_svc.set_supersedes(uid, note_id, data["supersedes"] or [])
except PermissionError as exc:
return jsonify({"error": str(exc)}), 403
# Set-semantics like the above: present-and-empty clears, absent leaves
# alone. Scoped by the CALLER rather than owner_uid, for the same reason
# the supersedes call is (#47) — `set_record_systems` links only Systems
# the acting user can read, and an editor-share holder should tag from
# what they can see rather than inherit the owner's reach. This matches
# routes/tasks.py; routes/lessons.py and routes/snippets.py pass the owner
# for the same operation, which is a real inconsistency, recorded in #4249
# rather than changed here.
if "system_ids" in data:
await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or [])
out = note.to_dict()
await supersession_svc.attach_relations(uid, note_id, out)
return jsonify(out)