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>
This commit is contained in:
@@ -47,13 +47,14 @@ project subscribes to) and subscribed_rulebooks; consult those too. Full text
|
||||
(Why / How-to-apply) is available via get_rule(id).
|
||||
|
||||
Engineering and workflow rules live in Scribe. When you notice a pattern
|
||||
worth codifying, call create_rule. Do NOT add new engineering rules to
|
||||
CLAUDE.md or to ~/.claude/.../memory/feedback_*.md — those stores are
|
||||
reserved for facts about the user (preferences, role, communication style)
|
||||
and codebase onboarding pointers, respectively. Before creating a rule,
|
||||
call list_always_on_rules and list_rules(project_id=...) to avoid duplicates.
|
||||
Coordinate with the operator on whether a new rule belongs in an existing
|
||||
rulebook+topic or a new one.
|
||||
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
|
||||
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
|
||||
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
|
||||
— those stores are reserved for facts about the user (preferences, role,
|
||||
communication style) and codebase onboarding pointers, respectively. Before
|
||||
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||
in a project, an existing rulebook+topic, or a new rulebook.
|
||||
|
||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||
|
||||
@@ -55,6 +55,7 @@ async def get_project(project_id: int) -> dict:
|
||||
data["applicable_rules"] = applicable["rules"]
|
||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||
data["project_rules"] = applicable.get("project_rules", [])
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ async def create_rule(
|
||||
topic_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new rule under a topic.
|
||||
"""Create a new rule under a topic (cross-project rulebook rule).
|
||||
|
||||
Args:
|
||||
topic_id: The topic to attach the rule to.
|
||||
@@ -256,6 +256,9 @@ async def create_rule(
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
order_index: Display order within the topic (default 0).
|
||||
|
||||
For a rule that applies to a single project only, use create_project_rule
|
||||
instead — no rulebook+topic ceremony required.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
@@ -266,6 +269,35 @@ async def create_rule(
|
||||
return rule.to_dict()
|
||||
|
||||
|
||||
async def create_project_rule(
|
||||
project_id: int, statement: str, title: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
) -> dict:
|
||||
"""Create a rule scoped to a single project (no rulebook needed).
|
||||
|
||||
Use this when a rule only applies to one project — it bypasses the
|
||||
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
|
||||
applicable_rules (under project_rules) and in list_rules(project_id=...).
|
||||
|
||||
Args:
|
||||
project_id: The project to attach the rule to.
|
||||
statement: The actionable instruction (required). 1-2 sentences.
|
||||
title: Short imperative title. If empty, derived from the first ~50
|
||||
characters of statement.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
order_index: Display order within the project's rule list (default 0).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
derived_title = title.strip() or statement.strip().split(".")[0][:50]
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id, user_id=uid,
|
||||
title=derived_title, statement=statement,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
)
|
||||
return rule.to_dict()
|
||||
|
||||
|
||||
async def update_rule(
|
||||
rule_id: int, title: str = "", statement: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||
@@ -336,7 +368,8 @@ def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
list_topics, create_topic, update_topic, delete_topic,
|
||||
list_rules, list_always_on_rules, get_rule, create_rule, update_rule, delete_rule,
|
||||
list_rules, list_always_on_rules, get_rule,
|
||||
create_rule, create_project_rule, update_rule, delete_rule,
|
||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -82,6 +82,7 @@ async def get_task(task_id: int) -> dict:
|
||||
data["applicable_rules"] = applicable["rules"]
|
||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||
data["project_rules"] = applicable.get("project_rules", [])
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -81,8 +81,18 @@ class Rule(Base, SoftDeleteMixin):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
topic_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
|
||||
# Exactly one of topic_id / project_id is set — enforced by CHECK
|
||||
# constraint ck_rule_topic_xor_project (migration 0059).
|
||||
topic_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
statement: Mapped[str] = mapped_column(Text)
|
||||
@@ -102,6 +112,7 @@ class Rule(Base, SoftDeleteMixin):
|
||||
return {
|
||||
"id": self.id,
|
||||
"topic_id": self.topic_id,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"statement": self.statement,
|
||||
"why": self.why or "",
|
||||
|
||||
@@ -239,3 +239,27 @@ async def get_project_rules(project_id: int):
|
||||
project_id=project_id, user_id=_uid(),
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||
@login_required
|
||||
async def create_project_rule(project_id: int):
|
||||
"""Create a rule scoped to a single project. Frontend fast path."""
|
||||
data = await request.get_json() or {}
|
||||
statement = (data.get("statement") or "").strip()
|
||||
if not statement:
|
||||
return jsonify({"error": "statement is required"}), 400
|
||||
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
|
||||
try:
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id,
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
statement=statement,
|
||||
why=data.get("why", ""),
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(rule.to_dict()), 201
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user