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:
@@ -243,7 +243,7 @@ async def test_subscribe_project_requires_owned_rulebook():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_returns_shape():
|
||||
"""get_applicable_rules returns {rules, truncated, subscribed_rulebooks}."""
|
||||
"""get_applicable_rules returns {rules, project_rules, truncated, subscribed_rulebooks}."""
|
||||
mock_session = _make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||
@@ -252,7 +252,9 @@ async def test_get_applicable_rules_returns_shape():
|
||||
(i, f"Rule {i}", f"Statement {i}", "git-workflow", "FabledSword family")
|
||||
for i in range(50)
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = []
|
||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result])
|
||||
|
||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
@@ -260,10 +262,12 @@ async def test_get_applicable_rules_returns_shape():
|
||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||
|
||||
assert "rules" in result
|
||||
assert "project_rules" in result
|
||||
assert "truncated" in result
|
||||
assert "subscribed_rulebooks" in result
|
||||
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
||||
assert len(result["rules"]) == 50
|
||||
assert result["project_rules"] == []
|
||||
assert result["truncated"] is False # exactly 50, not over
|
||||
|
||||
|
||||
@@ -278,7 +282,9 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
rules_result.all.return_value = [
|
||||
(i, f"r{i}", "stmt", "topic", "rb") for i in range(51)
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = []
|
||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result])
|
||||
|
||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
@@ -287,3 +293,28 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
|
||||
assert result["truncated"] is True
|
||||
assert len(result["rules"]) == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
"""Project-scoped rules surface in the project_rules field."""
|
||||
mock_session = _make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = []
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = []
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = [
|
||||
(100, "Use alembic", "Always run migrations via alembic, never raw SQL."),
|
||||
(101, "PR-bound", "Land schema changes in their own PR."),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result])
|
||||
|
||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
|
||||
assert len(result["project_rules"]) == 2
|
||||
assert result["project_rules"][0]["title"] == "Use alembic"
|
||||
assert result["project_rules"][1]["id"] == 101
|
||||
|
||||
Reference in New Issue
Block a user