feat(rules): move a rule between global and project scope, keeping its id, history, areas and edges (#4063)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 33s

A rule's home is its reach: a rulebook topic makes it global, a project makes it
that project's. There was no way to change one, so a project rule decided to be
global could only be recreated and the original trashed — losing the id every
record cites, its edit history, its area tags and its relations.

- services.rulebooks.move_rule(rule_id, user_id, topic_id= | project_id=):
  exactly one destination (the model's CHECK), owned by the caller, not the
  rule's current home. A topic already holding a live rule with the same title
  is refused with a message naming that rule, instead of uq_rule_per_topic
  failing the commit. Someone else's rule reads as not found.
- Deliberately NOT done, and said in the docstring: no version (a version is
  what a rule said, milestone 323 decision 4), no duplicate gate (nothing new
  enters the corpus), no re-embed (retrieval reads the home at query time).
- Both doors: MCP move_rule, REST POST /api/rules/<id>/move (rule 33).
- UI: RuleHomePicker, one component in the rule editor (a global rule) and a
  project's rules tab (a project rule), so the two cannot drift on what a
  destination is.
- using-scribe names move_rule under "Where a new rule goes". Plugin
  2026.09.15.1626.

Milestone 414 step 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 12:26:39 -04:00
co-authored by Claude Opus 5
parent b6751e4214
commit 4e4020c040
14 changed files with 471 additions and 6 deletions
+65
View File
@@ -688,6 +688,71 @@ async def update_rule(
return rule
async def move_rule(
rule_id: int, user_id: int, *, topic_id: int = 0, project_id: int = 0,
) -> Optional[Rule]:
"""Give a rule a new home — into a rulebook topic (global) or onto a
project — keeping its id, history, Systems and relations (milestone 414).
A rule's home IS its reach: in a topic it applies to every project, on a
project to that project alone. Recreating the rule in the other home and
trashing the original would lose its id (and every record citing it), its
edit history, its area tags and its typed edges, which is why this exists.
Exactly one of `topic_id` / `project_id`, matching the model's CHECK
(migration 0059). Raises ValueError for: neither or both named, a target
the caller does not own, the rule already living there, or a topic that
already holds a live rule with this title (uq_rule_per_topic) — the message
names that rule, rather than letting the constraint fail the commit.
Returns None when the rule itself is not the caller's.
WHAT A MOVE DOES NOT DO, deliberately:
- No version. A rule's history records its TEXT (milestone 323, decision
4); its place is not text, and folding it in would make "version" mean
two things. The rule's `updated_at` moves; say why a rule moved where
the decision is recorded.
- No duplicate gate. Nothing new enters the corpus — the same rule changes
home — so there is no second record to warn about.
- No re-embed. The rule's document is its title, statement and trigger;
retrieval reads the home from the row at query time.
"""
if bool(topic_id) == bool(project_id):
raise ValueError("name exactly one destination: topic_id (global) or project_id")
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
if topic_id:
if rule.topic_id == topic_id:
raise ValueError(f"rule {rule_id} is already in topic {topic_id}")
await _assert_topic_owned(session, topic_id, user_id)
clash = (await session.execute(
select(Rule.id).where(
Rule.topic_id == topic_id,
Rule.title == rule.title,
Rule.deleted_at.is_(None),
Rule.id != rule.id,
)
)).scalar_one_or_none()
if clash is not None:
raise ValueError(
f'topic {topic_id} already has a rule titled "{rule.title}" '
f"(rule {clash}) — rename one before moving"
)
rule.project_id = None
rule.topic_id = topic_id
else:
if rule.project_id == project_id:
raise ValueError(f"rule {rule_id} is already on project {project_id}")
await _assert_project_owned(session, project_id, user_id)
rule.topic_id = None
rule.project_id = project_id
await session.commit()
await session.refresh(rule)
return rule
# ── Edit history (milestone 323) ───────────────────────────────────────
#
# The ACL-scoped reads live HERE rather than in services/rule_versions.py,