Files
FabledGrimoire/src/scribe/routes/lessons.py
T
bvandeusenandClaude Opus 5 43ed7f4db7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 26s
fix(access): tag a record as the user doing it, not as its owner (#4249)
`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
2026-09-21 16:09:41 -04:00

272 lines
11 KiB
Python

"""REST routes for lessons — a transferable insight, retrievable by situation.
A lesson is a note with note_type='lesson' (see services/lessons.py). These
routes feed the web UI; the MCP tools (mcp/tools/lessons.py) are the
agent-facing surface. Both go through services/lessons.py, so the compose/parse
contract and the `data` mirror live in one place — the same division snippets
use, and for the same reason: two doors that each compose a lesson would
compose it two ways, and the document IS what ranks.
ACL (rule #78): reads and writes of a single lesson resolve through the
share-aware `get_lesson` / `can_write_note`, and writes are performed as the
OWNER so a shared editor isn't rejected by the owner-scoped service —
mirroring routes/snippets.py and routes/notes.py.
WHY THE TRIGGER IS A NAMED FIELD HERE TOO. The web editor could have posted a
body and let the service parse it. It doesn't, because the evidence behind
this kind (step 1) is that a trigger gets filled when a door ASKS for it by
name — the snippet corpus is at 100% on its trigger with no guard anywhere,
because a service composes the title from a parameter. A form that offered one
markdown box would be the option milestone 385 rejected, wearing a different
hat.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import lessons as lessons_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.access import (
can_write_note,
describe_provenance,
label_shared_items,
)
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
logger = logging.getLogger(__name__)
lessons_bp = Blueprint("lessons", __name__, url_prefix="/api/lessons")
@lessons_bp.route("", methods=["GET"])
@login_required
async def list_lessons_route():
"""The kind enumerated, rather than only what a query resembles.
Semantic search is how a lesson REACHES a session; this is how a person
sees what exists at all. Each row carries its trigger, because a list of
lessons without them is a list of claims with the half that says when each
one matters left off.
"""
uid = get_current_user_id()
q = request.args.get("q") or None
tag = request.args.get("tag", "")
try:
project_id = int(request.args.get("project_id", 0) or 0) or None
except (TypeError, ValueError):
project_id = None
limit, offset = parse_pagination(default_limit=24, max_limit=100)
items, total = await knowledge_svc.query_knowledge(
user_id=uid,
note_type=lessons_svc.LESSON_NOTE_TYPE,
tags=[tag] if tag else [],
sort="modified",
q=q,
limit=limit,
offset=offset,
project_id=project_id,
)
items = await label_shared_items(uid, items)
# One aggregate for the whole page — a per-row lookup would be N+1 by
# construction. Every row gets the key, zero-filled, so the UI renders
# "never surfaced" rather than treating a missing field as a state.
#
# Lessons were the one kind collecting this and showing it nowhere (#4196).
# The counts matter more here than on a snippet: the promotion question a
# lesson eventually raises — does this bind? — is answered by repeatedly
# surfaced AND repeatedly opened, and the far commoner reading of the same
# row is that the trigger fires on the wrong situation, which `update_lesson`
# exists to fix.
usage = await usage_for_notes([int(it["id"]) for it in items])
for it in items:
it["usage"] = usage.get(int(it["id"]), empty_usage())
return jsonify({"lessons": items, "total": total})
@lessons_bp.route("/taught-by/<int:record_id>", methods=["GET"])
@login_required
async def lessons_taught_by_route(record_id: int):
"""The lessons drawn FROM one record — the reverse of `learned_from`.
Registered ABOVE the `/<int:lesson_id>` routes on purpose: Quart matches
in registration order, and `taught-by` would otherwise never be reached
if the converter ever widened. The same ordering snippets' `/duplicates`
route documents.
This is the direction that gets forgotten. A reader opening an old issue
wants to know what was learned from it, and without this the relation is
only navigable from the lesson's side.
"""
uid = get_current_user_id()
notes = await lessons_svc.lessons_taught_by(uid, record_id)
return jsonify({
"lessons": [lessons_svc.lesson_to_dict(n) for n in notes],
"taught_by": record_id,
})
@lessons_bp.route("", methods=["POST"])
@login_required
async def create_lesson_route():
uid = get_current_user_id()
data = await request.get_json() or {}
what = (data.get("what") or "").strip()
when_to_apply = (data.get("when_to_apply") or "").strip()
if not what:
return jsonify({"error": "what is required"}), 400
# The trigger is not optional at this door even though the service will
# store a lesson without one. A lesson with no trigger saves, reads
# correctly in every listing, and never surfaces — there is nothing to
# notice afterwards, which is exactly why the form has to refuse it here
# rather than leave the writer a record that looks finished.
if not when_to_apply:
return jsonify({
"error": "when_to_apply is required",
"detail": (
"A lesson is found by the SITUATION it applies to. Without a "
"trigger it still saves and still reads correctly, and it "
"never reaches anyone — so it is refused here rather than "
"stored as a record that looks finished."
),
}), 400
project_id = data.get("project_id") or None
learned_from = data.get("learned_from") or []
# The same near-duplicate gate the MCP create path applies. Two lessons
# under one trigger compete in a single ranked list for one reserved slot,
# so the duplicate does not merely clutter — it displaces.
if not data.get("force"):
title, body = lessons_svc.lesson_document(
what, when_to_apply, data.get("insight", ""), learned_from,
)
dup = await dedup_svc.find_duplicate_note(
uid, title, body,
project_id=project_id,
is_task=False,
note_type=lessons_svc.LESSON_NOTE_TYPE,
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "lesson")), 409
note = await lessons_svc.create_lesson(
uid,
what=what,
when_to_apply=when_to_apply,
insight=data.get("insight", ""),
learned_from=learned_from,
tags=data.get("tags"),
project_id=project_id,
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = lessons_svc.lesson_to_dict(note)
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return jsonify(out), 201
@lessons_bp.route("/<int:lesson_id>", methods=["GET"])
@login_required
async def get_lesson_route(lesson_id: int):
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
out = lessons_svc.lesson_to_dict(note)
# As the OWNER: a shared reader isn't scoped to the owner's project, so
# their own id would come back empty (the write-as-owner pattern this
# module already uses, read side).
out["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, lesson_id)
]
# Resolved, not bare ids: "#4181" on a page tells a reader nothing about
# whether it is worth opening, and the provenance is the point of a lesson.
out["learned_from_records"] = await lessons_svc.source_records(
uid, out["learned_from"]
)
out.update(await describe_provenance(uid, note))
out["usage"] = (await usage_for_notes([lesson_id])).get(
lesson_id, empty_usage()
)
# Opening the detail view IS a pull — the operator chose to look. Tagged
# apart from the MCP sources so "an agent was handed it" and "a human read
# it" stay distinguishable; they mean different things for pruning (#2085).
record_pulled(user_id=uid, note_id=lesson_id, source="rest_lesson")
return jsonify(out)
@lessons_bp.route("/<int:lesson_id>", methods=["PATCH"])
@login_required
async def update_lesson_route(lesson_id: int):
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
if not await can_write_note(uid, lesson_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note.user_id
data = await request.get_json() or {}
# Partial update: only keys present in the payload change, and the service
# re-composes title, body and mirror from the merged set — so a form that
# sends one field cannot leave the halves of the document disagreeing.
kwargs = {
k: data[k]
for k in ("what", "when_to_apply", "insight", "learned_from", "tags")
if k in data
}
# An empty trigger would save and silently stop the lesson surfacing, so
# clearing it is refused for the same reason creating without one is.
if "when_to_apply" in kwargs and not (kwargs["when_to_apply"] or "").strip():
return jsonify({
"error": "when_to_apply cannot be cleared",
"detail": (
"A lesson with no trigger never surfaces, and nothing about "
"the stored record would show it. Rewrite the trigger rather "
"than emptying it."
),
}), 400
updated = await lessons_svc.update_lesson(owner_uid, lesson_id, **kwargs)
if updated is None:
return not_found("Lesson")
if data.get("system_ids") is not None:
# The CALLER, not owner_uid (#4249). `set_record_systems` runs its own
# `can_write_note` and links only Systems the acting user can read;
# handing it the owner makes that check trivially pass and filters by
# the owner's visibility instead. The caller's write permission is
# already established above, so this neither loosens nor tightens who
# may edit — it decides WHOSE reach the tagging uses (#47).
await systems_svc.set_record_systems(uid, lesson_id, data["system_ids"])
out = lessons_svc.lesson_to_dict(updated)
out["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(owner_uid, lesson_id)
]
return jsonify(out)
@lessons_bp.route("/<int:lesson_id>", methods=["DELETE"])
@login_required
async def delete_lesson_route(lesson_id: int):
"""Trash, not erase — recoverable from the trash like every other kind."""
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
if not await can_write_note(uid, lesson_id):
return jsonify({"error": "Permission denied"}), 403
batch_id = await trash_svc.delete(note.user_id, "note", lesson_id)
if batch_id is None:
return not_found("Lesson")
return jsonify({"deleted": lesson_id, "deleted_batch_id": batch_id})