feat(moments): rules mount on moments, through every rule door (milestone 458 step 3, #4921)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 29s

rule_moments (migration 0117) records which moments a rule arrives at, by
catalog name, cascading with the rule. rule_detail, the one seam every
rule door already returns through, gains moments beside system_ids: None
leaves the mounts alone, a list replaces them. get_rule and both
list_rules doors read them back, batched per page.

All five MCP rule/preference writes and the three REST ones take moments
and validate them before their create or update. An unknown name is
refused with the catalog listed and leaves no half-made rule behind; a
parity test pins that ordering on every door.

Backup v22 carries the mounts as a join table remapped through the rule
map; a real-Postgres round trip checks they land on the restored rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 11:03:51 -04:00
co-authored by Claude Opus 5.5
parent cf3de5bae1
commit cc26054437
13 changed files with 560 additions and 26 deletions
+28 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
import scribe.services.moments as moments_svc
import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete
from scribe.services.rule_usage import (
@@ -17,6 +18,15 @@ from scribe.services.rule_usage import (
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _moments_or_refusal(data: dict):
"""The request's `moments`, validated BEFORE any write — so an unknown
name is a 400 and not a rule created without the mounts it asked for.
Absent means "leave them alone" (None), as on the MCP door."""
try:
return moments_svc.require_moments(data.get("moments")), None
except ValueError as exc:
return None, (jsonify({"error": str(exc)}), 400)
# ── Rulebooks ───────────────────────────────────────────────────────────
@@ -154,8 +164,12 @@ async def list_rules():
# 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])
# Zero-filled like `usage`, for the same reason: the UI renders "mounted
# nowhere" from an empty list rather than treating a missing key as a state.
mounted = await rulebooks_svc.list_rule_moments([int(it["id"]) for it in items])
for it in items:
it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
it["moments"] = mounted.get(int(it["id"]), [])
return jsonify({"rules": items})
@@ -167,6 +181,9 @@ async def create_rule(topic_id: int):
statement = (data.get("statement") or "").strip()
if not title or not statement:
return jsonify({"error": "title and statement are required"}), 400
moments, refused = _moments_or_refusal(data)
if refused:
return refused
try:
rule = await rulebooks_svc.create_rule(
topic_id=topic_id,
@@ -189,7 +206,7 @@ async def create_rule(topic_id: int):
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"),
get_current_user_id(), rule, data.get("system_ids"), moments,
)), 201
@@ -223,10 +240,15 @@ async def update_rule(rule_id: int):
# 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.
moments, refused = _moments_or_refusal(data)
if refused:
return refused
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")))
return jsonify(await rulebooks_svc.rule_detail(
uid, rule, data.get("system_ids"), moments,
))
@rulebooks_bp.get("/rules/<int:rule_id>/versions")
@@ -401,6 +423,9 @@ async def create_project_rule(project_id: int):
"surfaces at the moment it applies."
}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
moments, refused = _moments_or_refusal(data)
if refused:
return refused
try:
rule = await rulebooks_svc.create_project_rule(
project_id=project_id,
@@ -423,7 +448,7 @@ async def create_project_rule(project_id: int):
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"),
get_current_user_id(), rule, data.get("system_ids"), moments,
)), 201