CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 1m7s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 33s
The write path, and the step where a preference stops being a relabelled rule. `create_preference` / `update_preference` on the MCP surface, plus `kind` on update_rule and both HTTP doors. SEPARATE TOOLS, NOT A `kind=` ARGUMENT. create_rule's docstring IS the approval gate (#3557): propose, offer three answers, wait. That is right for a rule — the person it binds should have agreed. A preference inverts it, and one reached through create_rule would be read through that prose, so the caller would hesitate over exactly the act this kind exists to make routine. Two doors, two contracts, one table. Reads stay shared: a preference IS a rule row, and "what governs this" wants both. Two required fields, each buying something: - `when_to_apply`, because the trigger is two-thirds of the embedded document. Without one the record is written, stored, and silently never delivered — indistinguishable from one nobody wrote. - `arose_from_id`, the price of the ungated write. A corpus that drifts with no record of what taught each change cannot be audited, and the operator's veto over drift is worth exactly as much as their ability to read why it happened. The near-duplicate gate is what lets this corpus be written freely and stay small: the second preference about a thing updates the first. It is title-scoped and kind-blind, so it also catches a preference restating a rule that already binds. The asymmetry is guarded as two PRESENCE facts — the rule door still asks, the preference door still says write it — never as an absence. An absence check passes against a docstring that was deleted or rewritten into something else, which is snippet #3352's warning and would read as coverage here while proving nothing. `_plain_detail` moved to tests/helpers on its second copy, per that module's own reason for existing (#2825). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
511 lines
20 KiB
Python
511 lines
20 KiB
Python
"""Rulebook / topic REST endpoints.
|
|
|
|
Wraps services/rulebooks.py. Standard Scribe auth: get_current_user_id() is the
|
|
authenticated owner; the service enforces ownership scoping.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import get_current_user_id, login_required
|
|
import scribe.services.rulebooks as rulebooks_svc
|
|
from scribe.services.trash import delete as trash_delete
|
|
from scribe.services.rule_usage import (
|
|
empty_rule_usage, record_rule_pulled, usage_for_rules,
|
|
)
|
|
|
|
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
|
|
|
|
|
|
|
# ── Rulebooks ───────────────────────────────────────────────────────────
|
|
|
|
@rulebooks_bp.get("/rulebooks")
|
|
@login_required
|
|
async def list_rulebooks():
|
|
rows = await rulebooks_svc.list_rulebooks(get_current_user_id())
|
|
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
|
|
|
|
|
|
@rulebooks_bp.post("/rulebooks")
|
|
@login_required
|
|
async def create_rulebook():
|
|
data = await request.get_json() or {}
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
return jsonify({"error": "title is required"}), 400
|
|
rb = await rulebooks_svc.create_rulebook(
|
|
user_id=get_current_user_id(),
|
|
title=title,
|
|
description=data.get("description", ""),
|
|
)
|
|
return jsonify(rb.to_dict()), 201
|
|
|
|
|
|
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
|
|
@login_required
|
|
async def get_rulebook(rulebook_id: int):
|
|
rb = await rulebooks_svc.get_rulebook(rulebook_id, get_current_user_id())
|
|
if rb is None:
|
|
return jsonify({"error": "rulebook not found"}), 404
|
|
return jsonify(rb.to_dict())
|
|
|
|
|
|
@rulebooks_bp.patch("/rulebooks/<int:rulebook_id>")
|
|
@login_required
|
|
async def update_rulebook(rulebook_id: int):
|
|
data = await request.get_json() or {}
|
|
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
|
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
|
|
if rb is None:
|
|
return jsonify({"error": "rulebook not found"}), 404
|
|
return jsonify(rb.to_dict())
|
|
|
|
|
|
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
|
|
@login_required
|
|
async def delete_rulebook(rulebook_id: int):
|
|
await trash_delete(get_current_user_id(), "rulebook", rulebook_id)
|
|
return "", 204
|
|
|
|
|
|
# ── Topics ──────────────────────────────────────────────────────────────
|
|
|
|
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>/topics")
|
|
@login_required
|
|
async def list_topics(rulebook_id: int):
|
|
try:
|
|
rows = await rulebooks_svc.list_topics(rulebook_id, get_current_user_id())
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return jsonify({"topics": [t.to_dict() for t in rows]})
|
|
|
|
|
|
@rulebooks_bp.post("/rulebooks/<int:rulebook_id>/topics")
|
|
@login_required
|
|
async def create_topic(rulebook_id: int):
|
|
data = await request.get_json() or {}
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
return jsonify({"error": "title is required"}), 400
|
|
try:
|
|
topic = await rulebooks_svc.create_topic(
|
|
rulebook_id=rulebook_id,
|
|
user_id=get_current_user_id(),
|
|
title=title,
|
|
description=data.get("description", ""),
|
|
order_index=data.get("order_index", 0),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return jsonify(topic.to_dict()), 201
|
|
|
|
|
|
@rulebooks_bp.patch("/rulebook-topics/<int:topic_id>")
|
|
@login_required
|
|
async def update_topic(topic_id: int):
|
|
data = await request.get_json() or {}
|
|
fields = {
|
|
k: v for k, v in data.items()
|
|
if k in ("title", "description", "order_index")
|
|
}
|
|
topic = await rulebooks_svc.update_topic(topic_id, get_current_user_id(), **fields)
|
|
if topic is None:
|
|
return jsonify({"error": "topic not found"}), 404
|
|
return jsonify(topic.to_dict())
|
|
|
|
|
|
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
|
|
@login_required
|
|
async def delete_topic(topic_id: int):
|
|
if await trash_delete(get_current_user_id(), "topic", topic_id) is None:
|
|
return jsonify({"error": "topic not found"}), 404
|
|
return "", 204
|
|
|
|
|
|
# ── Rules ───────────────────────────────────────────────────────────────
|
|
|
|
@rulebooks_bp.get("/rules")
|
|
@login_required
|
|
async def list_rules():
|
|
def _opt_int(name):
|
|
raw = request.args.get(name)
|
|
return int(raw) if raw else None
|
|
|
|
try:
|
|
rulebook_id = _opt_int("rulebook_id")
|
|
topic_id = _opt_int("topic_id")
|
|
project_id = _opt_int("project_id")
|
|
except ValueError:
|
|
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
|
|
|
uid = get_current_user_id()
|
|
rows = await rulebooks_svc.list_rules(
|
|
user_id=uid,
|
|
rulebook_id=rulebook_id,
|
|
topic_id=topic_id,
|
|
project_id=project_id,
|
|
)
|
|
items = [r.to_dict() for r in rows]
|
|
# One aggregate for the whole page — a per-row lookup here would be N+1 by
|
|
# construction, the same reason the snippet list does it this way. Every
|
|
# row gets the key, zero-filled, so the UI renders "never surfaced" rather
|
|
# than having to treat a missing field as a state. That matters more here
|
|
# than for snippets: every rule on every install predates this table, so
|
|
# for a while the zero-filled shape IS the common case.
|
|
usage = await usage_for_rules([int(it["id"]) for it in items])
|
|
for it in items:
|
|
it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
|
|
return jsonify({"rules": items})
|
|
|
|
|
|
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
|
|
@login_required
|
|
async def create_rule(topic_id: int):
|
|
data = await request.get_json() or {}
|
|
title = (data.get("title") or "").strip()
|
|
statement = (data.get("statement") or "").strip()
|
|
if not title or not statement:
|
|
return jsonify({"error": "title and statement are required"}), 400
|
|
try:
|
|
rule = await rulebooks_svc.create_rule(
|
|
topic_id=topic_id,
|
|
user_id=get_current_user_id(),
|
|
title=title,
|
|
statement=statement,
|
|
why=data.get("why", ""),
|
|
how_to_apply=data.get("how_to_apply", ""),
|
|
order_index=data.get("order_index", 0),
|
|
when_to_apply=data.get("when_to_apply", ""),
|
|
tier=data.get("tier", "always_on"),
|
|
# The human door carries `kind` too, and without the MCP door's
|
|
# required provenance: an operator editing their own preference
|
|
# owes nobody an explanation. That requirement is about auditing
|
|
# what the AGENT changed, not what they did themselves.
|
|
kind=data.get("kind", "rule"),
|
|
arose_from_id=data.get("arose_from_id", 0) or 0,
|
|
verify_with=data.get("verify_with", ""),
|
|
expires_when=data.get("expires_when", ""),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return jsonify(await rulebooks_svc.rule_detail(
|
|
get_current_user_id(), rule, data.get("system_ids"),
|
|
)), 201
|
|
|
|
|
|
@rulebooks_bp.get("/rules/<int:rule_id>")
|
|
@login_required
|
|
async def get_rule(rule_id: int):
|
|
uid = get_current_user_id()
|
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
|
if rule is None:
|
|
return jsonify({"error": "rule not found"}), 404
|
|
# `rest_` rather than `mcp_`, and the prefix is load-bearing: "is this rule
|
|
# dead weight?" is served by any pull, but "did that injected hint land?"
|
|
# — the question this arm exists to answer — is served by AGENT pulls only.
|
|
# A person clicking through the rule list says nothing about the hint.
|
|
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="rest_rule")
|
|
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
|
|
|
|
|
@rulebooks_bp.patch("/rules/<int:rule_id>")
|
|
@login_required
|
|
async def update_rule(rule_id: int):
|
|
data = await request.get_json() or {}
|
|
uid = get_current_user_id()
|
|
fields = {
|
|
k: v for k, v in data.items()
|
|
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
|
"when_to_apply", "tier", "kind", "arose_from_id",
|
|
"verify_with", "expires_when")
|
|
}
|
|
# No clear_fields here: a form sends "" for an emptied input, and the
|
|
# service normalises "" to NULL for every nullable text column. The MCP
|
|
# door needs the explicit list only because "" already means "unchanged"
|
|
# there — two idioms, one outcome.
|
|
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
|
if rule is None:
|
|
return jsonify({"error": "rule not found"}), 404
|
|
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
|
|
|
|
|
|
@rulebooks_bp.get("/rules/<int:rule_id>/versions")
|
|
@login_required
|
|
async def list_rule_versions(rule_id: int):
|
|
"""A rule's edit history, newest first.
|
|
|
|
Listing form only — a rule's `statement` and `why` run to thousands of
|
|
characters, so a history list carrying every field would be unreadable
|
|
and expensive to send. Open one for the text.
|
|
"""
|
|
uid = get_current_user_id()
|
|
versions = await rulebooks_svc.list_rule_versions(rule_id, uid)
|
|
if versions is None:
|
|
return jsonify({"error": "rule not found"}), 404
|
|
return jsonify({
|
|
"versions": [v.to_dict(include_text=False) for v in versions],
|
|
})
|
|
|
|
|
|
@rulebooks_bp.get("/rules/<int:rule_id>/versions/<int:version_id>")
|
|
@login_required
|
|
async def get_rule_version(rule_id: int, version_id: int):
|
|
"""One snapshot in full — what the rule said before that edit."""
|
|
uid = get_current_user_id()
|
|
version = await rulebooks_svc.get_rule_version(rule_id, version_id, uid)
|
|
if version is None:
|
|
return jsonify({"error": "version not found"}), 404
|
|
return jsonify(version.to_dict(include_text=True))
|
|
|
|
|
|
# NO restore route, deliberately (milestone 323). A note version can be
|
|
# restored; a binding instruction should not be revertible in one click.
|
|
# Putting a rewrite back goes through update_rule, which takes its own
|
|
# snapshot and leaves the undo in the history like any other edit — a silent
|
|
# revert would erase the only record of why the rewrite happened.
|
|
|
|
|
|
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
|
|
@login_required
|
|
async def relate_rules(rule_id: int):
|
|
"""Draw a typed edge FROM this rule to another.
|
|
|
|
Body: {"to_rule_id": N, "kind": "co_surfaces"|"overrides"|"elaborates",
|
|
"note": "..."}. Idempotent — re-drawing an edge returns the existing one.
|
|
"""
|
|
data = await request.get_json() or {}
|
|
to_rule_id = data.get("to_rule_id")
|
|
if not isinstance(to_rule_id, int):
|
|
return jsonify({"error": "to_rule_id is required"}), 400
|
|
try:
|
|
relation = await rulebooks_svc.add_rule_relation(
|
|
get_current_user_id(), rule_id, to_rule_id,
|
|
data.get("kind", ""), data.get("note", ""),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
if relation is None:
|
|
return jsonify({"error": "rule not found"}), 404
|
|
return jsonify(relation.to_dict()), 201
|
|
|
|
|
|
@rulebooks_bp.delete("/rule-relations/<int:relation_id>")
|
|
@login_required
|
|
async def unrelate_rules(relation_id: int):
|
|
if not await rulebooks_svc.remove_rule_relation(get_current_user_id(), relation_id):
|
|
return jsonify({"error": "relation not found"}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
|
@login_required
|
|
async def delete_rule(rule_id: int):
|
|
if await trash_delete(get_current_user_id(), "rule", rule_id) is None:
|
|
return jsonify({"error": "rule not found"}), 404
|
|
return "", 204
|
|
|
|
|
|
# ── Subscriptions ──────────────────────────────────────────────────────
|
|
|
|
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
|
|
@login_required
|
|
async def subscribe_project(project_id: int):
|
|
data = await request.get_json() or {}
|
|
rulebook_id = data.get("rulebook_id")
|
|
if not rulebook_id:
|
|
return jsonify({"error": "rulebook_id is required"}), 400
|
|
try:
|
|
await rulebooks_svc.subscribe_project(
|
|
project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.delete(
|
|
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
|
|
)
|
|
@login_required
|
|
async def unsubscribe_project(project_id: int, rulebook_id: int):
|
|
try:
|
|
await rulebooks_svc.unsubscribe_project(
|
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.get("/projects/<int:project_id>/rules")
|
|
@login_required
|
|
async def get_project_rules(project_id: int):
|
|
result = await rulebooks_svc.get_applicable_rules(
|
|
project_id=project_id, user_id=get_current_user_id(),
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
|
@login_required
|
|
async def suppress_project_rule(project_id: int, rule_id: int):
|
|
try:
|
|
await rulebooks_svc.suppress_rule_for_project(
|
|
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
|
@login_required
|
|
async def unsuppress_project_rule(project_id: int, rule_id: int):
|
|
try:
|
|
await rulebooks_svc.unsuppress_rule_for_project(
|
|
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
|
@login_required
|
|
async def suppress_project_topic(project_id: int, topic_id: int):
|
|
try:
|
|
await rulebooks_svc.suppress_topic_for_project(
|
|
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
|
@login_required
|
|
async def unsuppress_project_topic(project_id: int, topic_id: int):
|
|
try:
|
|
await rulebooks_svc.unsuppress_topic_for_project(
|
|
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
|
@login_required
|
|
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
|
|
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
|
|
try:
|
|
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
|
@login_required
|
|
async def include_project_rulebook(project_id: int, rulebook_id: int):
|
|
try:
|
|
await rulebooks_svc.include_always_on_rulebook_for_project(
|
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return "", 204
|
|
|
|
|
|
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
|
@login_required
|
|
async def create_project_rule(project_id: int):
|
|
"""Create a rule scoped to a single project. Frontend fast path."""
|
|
data = await request.get_json() or {}
|
|
statement = (data.get("statement") or "").strip()
|
|
if not statement:
|
|
return jsonify({"error": "statement is required"}), 400
|
|
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
|
|
try:
|
|
rule = await rulebooks_svc.create_project_rule(
|
|
project_id=project_id,
|
|
user_id=get_current_user_id(),
|
|
title=title,
|
|
statement=statement,
|
|
why=data.get("why", ""),
|
|
how_to_apply=data.get("how_to_apply", ""),
|
|
order_index=data.get("order_index", 0),
|
|
when_to_apply=data.get("when_to_apply", ""),
|
|
tier=data.get("tier", "always_on"),
|
|
# The human door carries `kind` too, and without the MCP door's
|
|
# required provenance: an operator editing their own preference
|
|
# owes nobody an explanation. That requirement is about auditing
|
|
# what the AGENT changed, not what they did themselves.
|
|
kind=data.get("kind", "rule"),
|
|
arose_from_id=data.get("arose_from_id", 0) or 0,
|
|
verify_with=data.get("verify_with", ""),
|
|
expires_when=data.get("expires_when", ""),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 404
|
|
return jsonify(await rulebooks_svc.rule_detail(
|
|
get_current_user_id(), rule, data.get("system_ids"),
|
|
)), 201
|
|
|
|
|
|
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
|
|
|
@rulebooks_bp.get("/rules-due-for-verification")
|
|
@login_required
|
|
async def rules_due_for_verification():
|
|
"""Rules that carry a check, oldest verification first, never-checked top.
|
|
|
|
Query params: older_than_days, tier, never_only. A rule with no
|
|
`verify_with` never appears — it is a decision, not a fact.
|
|
"""
|
|
uid = get_current_user_id()
|
|
args = request.args
|
|
try:
|
|
older = int(args.get("older_than_days", 0) or 0)
|
|
except ValueError:
|
|
return jsonify({"error": "older_than_days must be an integer"}), 400
|
|
try:
|
|
rules = await rulebooks_svc.rules_due_for_verification(
|
|
uid,
|
|
older_than_days=older,
|
|
tier=args.get("tier", ""),
|
|
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
|
)
|
|
except ValueError as exc:
|
|
# An unrecognised tier is a 400, not a silently narrowed result set:
|
|
# a filter that quietly answers a different question is the failure
|
|
# this whole surface exists to catch.
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify({
|
|
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
|
"total": len(rules),
|
|
})
|
|
|
|
|
|
@rulebooks_bp.post("/rules/<int:rule_id>/verify")
|
|
@login_required
|
|
async def mark_rule_verified(rule_id: int):
|
|
"""Record that the rule's check was run. Body: {"still_true": bool}.
|
|
|
|
`still_true: false` writes nothing — a rule whose check failed is wrong,
|
|
not in a recordable state — so it stays at the top of the sweep.
|
|
"""
|
|
data = await request.get_json() or {}
|
|
uid = get_current_user_id()
|
|
still_true = data.get("still_true", True)
|
|
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, bool(still_true))
|
|
if rule is None:
|
|
return jsonify({"error": "rule not found, or carries no verify_with"}), 404
|
|
payload = await rulebooks_svc.rule_detail(uid, rule)
|
|
payload["verified"] = bool(still_true)
|
|
return jsonify(payload)
|