feat(rules): project-scoped rules (S3)
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

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>
This commit is contained in:
2026-06-01 01:10:18 -04:00
parent 658348f208
commit 43a860c3ac
16 changed files with 491 additions and 59 deletions
+1
View File
@@ -58,6 +58,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
+133 -36
View File
@@ -242,6 +242,20 @@ async def _assert_topic_owned(session, topic_id: int, user_id: int) -> 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,
@@ -262,6 +276,32 @@ async def create_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,
@@ -270,7 +310,12 @@ async def list_rules(
) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
project_id resolves rules through project_rulebook_subscriptions.
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
@@ -302,7 +347,27 @@ async def list_rules(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
result = await session.execute(stmt)
return list(result.scalars().all())
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]:
@@ -332,35 +397,51 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
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:
result = 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),
)
)
return result.scalar_one_or_none()
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:
result = 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 = result.scalar_one_or_none()
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"}
@@ -374,16 +455,7 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async def delete_rule(rule_id: int, user_id: int) -> None:
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(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
)
)
rule = result.scalar_one_or_none()
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return
await session.delete(rule)
@@ -434,14 +506,19 @@ async def unsubscribe_project(
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project via its subscriptions.
"""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
@@ -500,8 +577,28 @@ async def get_applicable_rules(
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,
}
+2
View File
@@ -53,6 +53,8 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
if etype == "project":
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
# Project-scoped rules cascade with the project they're attached to.
await _set(session, Rule, [Rule.project_id == eid], batch, now)
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
elif etype == "milestone":
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)