Files
FabledScribe/src/fabledassistant/services/rulebooks.py
T
bvandeusen 43a860c3ac
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m7s
feat(rules): project-scoped rules (S3)
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.

- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
  topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
  project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
  with project_id filter UNIONs subscription-derived + project-scoped;
  get_applicable_rules adds a project_rules field; get_rule / update_rule
  / delete_rule fetch via a shared _fetch_owned_rule that handles both
  rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
  create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
  if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
  client; ProjectRulesTab.vue gains a "Project rules" section with inline
  create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
  fields, title derivation, explicit-title pass-through); applicable_rules
  shape tests now include project_rules; trash cascade test updated to
  expect 5 executions

S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:10:18 -04:00

605 lines
22 KiB
Python

"""Rulebook / topic / rule service layer — single source of truth used by
both routes/rulebooks.py and mcp/tools/rulebooks.py.
Ownership enforcement: every function takes user_id and scopes through
rulebooks.owner_user_id. Functions return models or model.to_dict() output
depending on the caller's needs (mirroring services/events.py pattern).
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.rulebook import Rulebook
logger = logging.getLogger(__name__)
# ── Rulebook CRUD ────────────────────────────────────────────────────────
async def create_rulebook(
user_id: int, title: str, description: str = "",
) -> Rulebook:
"""Create a new rulebook owned by user_id. Returns the persisted model."""
async with async_session() as session:
rb = Rulebook(
owner_user_id=user_id, title=title, description=description or None,
)
session.add(rb)
await session.commit()
await session.refresh(rb)
return rb
async def list_rulebooks(user_id: int) -> list[Rulebook]:
"""List rulebooks owned by user_id, ordered by title."""
async with async_session() as session:
result = await session.execute(
select(Rulebook)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
return list(result.scalars().all())
async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]:
"""Get a rulebook by id, scoped to user_id. None if not owned or not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def update_rulebook(
rulebook_id: int, user_id: int, **fields,
) -> Optional[Rulebook]:
"""Partial update. Returns updated rulebook or None if not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
)
)
rb = result.scalar_one_or_none()
if rb is None:
return None
allowed = {"title", "description", "always_on"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rb, key, value)
await session.commit()
await session.refresh(rb)
return rb
async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
"""Delete a rulebook. Cascade-deletes topics, rules, subscriptions."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
)
)
rb = result.scalar_one_or_none()
if rb is None:
return
await session.delete(rb)
await session.commit()
async def find_rulebook_by_title(
user_id: int, title: str,
) -> Optional[Rulebook]:
"""Used by the port script for the dupe-guard. None if not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.owner_user_id == user_id,
Rulebook.title == title,
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
# ── Topic CRUD ──────────────────────────────────────────────────────────
from fabledassistant.models.rulebook import RulebookTopic
async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None:
"""Raise ValueError if rulebook doesn't exist or isn't owned by user.
Centralizes ownership check used by all topic/rule operations.
"""
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rulebook {rulebook_id} not found")
async def create_topic(
rulebook_id: int, user_id: int, title: str,
description: str = "", order_index: int = 0,
) -> RulebookTopic:
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
topic = RulebookTopic(
rulebook_id=rulebook_id,
title=title,
description=description or None,
order_index=order_index,
)
session.add(topic)
await session.commit()
await session.refresh(topic)
return topic
async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
result = await session.execute(
select(RulebookTopic)
.where(
RulebookTopic.rulebook_id == rulebook_id,
RulebookTopic.deleted_at.is_(None),
)
.order_by(RulebookTopic.order_index, RulebookTopic.title)
)
return list(result.scalars().all())
async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
"""Get a topic, scoped via the rulebook owner."""
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def update_topic(
topic_id: int, user_id: int, **fields,
) -> Optional[RulebookTopic]:
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
)
)
topic = result.scalar_one_or_none()
if topic is None:
return None
allowed = {"title", "description", "order_index"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(topic, key, value)
await session.commit()
await session.refresh(topic)
return topic
async def delete_topic(topic_id: int, user_id: int) -> None:
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
)
)
topic = result.scalar_one_or_none()
if topic is None:
return
await session.delete(topic)
await session.commit()
# ── Rule CRUD ──────────────────────────────────────────────────────────
from fabledassistant.models.rulebook import Rule
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
"""Raise ValueError if topic doesn't exist or isn't in user's rulebook."""
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"topic {topic_id} not found")
async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
"""Raise ValueError if project doesn't exist or isn't owned by user."""
from fabledassistant.models.project import Project
result = await session.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"project {project_id} not found")
async def create_rule(
topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
async with async_session() as session:
await _assert_topic_owned(session, topic_id, user_id)
rule = Rule(
topic_id=topic_id,
title=title,
statement=statement,
why=why or None,
how_to_apply=how_to_apply or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
return rule
async def create_project_rule(
project_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't
propagate via rulebook subscriptions. Topic_id is left NULL — the
CHECK constraint enforces exactly-one of (topic_id, project_id).
"""
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
rule = Rule(
project_id=project_id,
title=title,
statement=statement,
why=why or None,
how_to_apply=how_to_apply or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
return rule
async def list_rules(
user_id: int,
rulebook_id: int | None = None,
topic_id: int | None = None,
project_id: int | None = None,
) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
When project_id is set, the result includes both rulebook rules reached via
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
When rulebook_id or topic_id is set, project-scoped rules are excluded by
construction (they have neither). With no filter, only rulebook rules are
returned — adding all of a user's project-scoped rules unprompted would
surprise existing callers.
"""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
stmt = (
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if topic_id:
stmt = stmt.where(Rule.topic_id == topic_id)
if rulebook_id:
stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
if project_id:
stmt = (
stmt.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(project_rulebook_subscriptions.c.project_id == project_id)
)
stmt = stmt.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
result = await session.execute(stmt)
rulebook_rules = list(result.scalars().all())
if not project_id:
return rulebook_rules
# Project-scoped rules (topic_id IS NULL, project_id matches).
# Verifies ownership by joining Project on user_id.
from fabledassistant.models.project import Project
proj_stmt = (
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_result = await session.execute(proj_stmt)
return rulebook_rules + list(proj_result.scalars().all())
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
"""Return all rules from rulebooks flagged always_on for the user.
Called by the MCP tool of the same name at session start to load the
standing rules that apply regardless of which project (if any) is in
scope. Ordering matches list_rules so results are stable across calls.
"""
async with async_session() as session:
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rulebook.owner_user_id == user_id,
Rulebook.always_on.is_(True),
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit)
)
return list(result.scalars().all())
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
"""Fetch a rule by id, scoped to user owning either its rulebook
(via topic) or its project (via project_id). Honors soft-delete.
Returns None when not found or not owned.
"""
from fabledassistant.models.project import Project
# Path A — rulebook rule.
rulebook_rule = (await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)).scalar_one_or_none()
if rulebook_rule is not None:
return rulebook_rule
# Path B — project-scoped rule.
project_rule = (await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Rule.id == rule_id,
Project.user_id == user_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)).scalar_one_or_none()
return project_rule
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
async with async_session() as session:
return await _fetch_owned_rule(session, rule_id, user_id)
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rule, key, value)
await session.commit()
await session.refresh(rule)
return rule
async def delete_rule(rule_id: int, user_id: int) -> None:
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return
await session.delete(rule)
await session.commit()
# ── Subscriptions + get_applicable_rules ───────────────────────────────
from sqlalchemy import insert, delete as sql_delete
async def subscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Add a subscription. Idempotent — duplicates raise; we swallow."""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
await session.execute(
insert(project_rulebook_subscriptions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already subscribed; fine.
async def unsubscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id == project_id,
project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project — both via rulebook subscriptions
and project-scoped rules (Rule.project_id matches).
Shape:
{
"rules": [{id, title, statement, topic_title, rulebook_title}, ...],
"project_rules": [{id, title, statement}, ...],
"truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
}
`rules` is the subscription-derived set (legacy shape preserved).
`project_rules` is the project-scoped set; empty list when none exist.
"""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
sub_q = (
select(Rulebook.id, Rulebook.title)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
sub_rows = (await session.execute(sub_q)).all()
subscribed_rulebooks = [
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Applicable rules (limit + 1 so we can detect truncation).
rules_q = (
select(
Rule.id, Rule.title, Rule.statement,
RulebookTopic.title.label("topic_title"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)
rule_rows = (await session.execute(rules_q)).all()
truncated = len(rule_rows) > limit
rules = [
{
"id": rid, "title": rtitle, "statement": stmt,
"topic_title": tt, "rulebook_title": rbt,
}
for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
]
# Project-scoped rules — verifies ownership via Project.user_id.
from fabledassistant.models.project import Project
proj_rules_q = (
select(Rule.id, Rule.title, Rule.statement)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [
{"id": rid, "title": rtitle, "statement": stmt}
for rid, rtitle, stmt in proj_rule_rows
]
return {
"rules": rules,
"project_rules": project_rules,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}