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
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user