fix(systems): System tagging works from whichever door wrote the record, as whoever wrote it (#4249) #178
Merged
bvandeusen
merged 2 commits from 2026-09-21 16:13:10 -04:00
dev into main
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
43ed7f4db7 |
fix(access): tag a record as the user doing it, not as its owner (#4249)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 26s
`set_record_systems` is not a dumb setter. It runs its own `can_write_note` and then links only the Systems the given user can READ. Four of twenty call sites handed it the record's owner instead of the acting user, which did two quiet things at once: the access check became trivially true, since an owner can always write their own record, and the System filter used the owner's visibility rather than the actor's. On a single-user install neither is observable. With a share it is an editor acting with the owner's reach — the shape rule 47 exists to prevent, and the same reasoning routes/notes.py already spells out for `set_supersedes` two lines away. This is NOT a permission change. Every one of the four sites establishes the caller's write access first: routes/lessons.py and routes/snippets.py call `can_write_note(uid, …)`, mcp/tools/processes.py does the same, and mcp/tools/snippets.py reaches `set_record_systems` only after `update_snippet` has raised PermissionError if the caller may not write. So nobody gains or loses the ability to edit anything. What changes is whose reach the tagging runs with, which is exactly the kind of difference that survives review because every call site reads fine on its own. The four: routes/lessons.py:243 owner_uid -> uid routes/snippets.py:211 owner_uid -> uid mcp/tools/snippets.py:486 note.user_id -> uid mcp/tools/processes.py:196 note.user_id -> uid The last one was written earlier in this same session, an hour before the sweep that found it, with a comment confidently explaining why the owner was correct. That is the argument for the guard rather than for care: the unified stance was known and still got it wrong at the next opportunity. So the guard is the point again. `test_every_tagging_write_acts_as_the_caller` walks every `set_record_systems` call in src/ and asserts the first argument is a bare local named `uid` or `user_id` — an attribute access is a record's owner by construction. Verified against `git show HEAD:` as well as the working tree: clean now, four offenders on the code it replaces. Reads are deliberately untouched. `list_record_systems(owner_uid, …)` gates on reading the NOTE, which the caller can do anyway, so it returns the same list either way; it is a different operation and churning it would add noise without changing behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
e2e1ea3667 |
fix(systems): a record can be filed under a System from whichever door wrote it (#4249)
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 lint (push) Successful in 3s
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
|