feat(rules): both doors carry the trigger, the tier, the areas and the edges (#3029, milestone 307 step 3, surfaces)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s

MCP and REST both gain when_to_apply / tier / system_ids / arose_from_id on
create and update, plus relate_rules / unrelate_rules for the typed edges, and
get_rule now returns a rule's areas and relations alongside it.

rule_detail() is a SERVICE function, not one per door. It started as a copy in
each — identical, and the prior-art hook flagged it immediately, which is the
same lesson rules_payload (#2858) already recorded: a second copy drifts. Both
doors call the one seam, so create, update and get cannot disagree about what a
rule looks like coming back.

The authoring guidance lands in create_rule's docstring rather than in a rule,
per rule 119 as the operator described it: this is behaviour every instance
should inherit, not one operator's preference. It states the test —

  ONE RULE = ONE THING YOU COULD VIOLATE. Rules that FAIL TOGETHER get linked
  with relate_rules(kind="co_surfaces"), never merged into one row.

— and names why merging loses: a merged rule cannot be cited, surfaced or
suppressed a clause at a time, and it grows without limit because adding to it
is always cheaper than adding a rule. create_project_rule says the same about
"overrides", which is what FabledCurator's 85/86 should have been instead of
near-copies that drift from their parent.

The tier arg carries the test itself: can you name the trigger WITHOUT naming a
system, an artifact type or a moment? If the honest answer is "whenever you are
working", it is always_on.

Tests: the applicable-rules cases fabricated raw tuples matching the old column
lists, so they move to the entity shape via fake_rule; new cases pin rule_brief
(a DATE not a stamp, the depth left to get_rule, no null keys) and that an
unknown tier falls back to BINDING. fake_rule gains when_to_apply / tier /
arose_from_id for the note-2109 reason the helper exists: unnamed, they would
be truthy MagicMocks. The tool tests stub the new rule_detail seam — they are
about argument forwarding and have no database.

The module header's "Sixteen tools" had been wrong for two milestones; the
registration count test is what actually catches that, so the header now says
so instead of carrying a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 14:17:41 -04:00
co-authored by Claude Opus 5
parent 6ddb8bf859
commit ffb7a0fe38
7 changed files with 346 additions and 39 deletions
+59 -5
View File
@@ -2,6 +2,7 @@
Mirrors the pattern in tests/test_events_service.py.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -206,8 +207,10 @@ async def test_get_applicable_rules_returns_shape():
sub_result.all.return_value = [(1, "FabledSword family")]
rules_result = MagicMock()
rules_result.all.return_value = [
# (rule_id, title, statement, topic_id, topic_title, rulebook_id, rulebook_title)
(i, f"Rule {i}", f"Statement {i}", 2, "git-workflow", 1, "FabledSword family")
# The query selects the ENTITY plus three labels, so rule_brief stays
# the one place deciding what a surfaced rule carries (note 3026).
(fake_rule(id=i, title=f"Rule {i}", statement=f"Statement {i}", topic_id=2),
"git-workflow", 1, "FabledSword family")
for i in range(50)
]
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q
@@ -244,7 +247,7 @@ async def test_get_applicable_rules_truncates_when_over_limit():
sub_result.all.return_value = []
rules_result = MagicMock()
rules_result.all.return_value = [
(i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51)
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
]
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), rules_result, _empty(),
@@ -265,8 +268,10 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
mock_session = make_mock_session()
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."),
(fake_rule(id=100, topic_id=None, project_id=3, title="Use alembic",
statement="Always run migrations via alembic, never raw SQL."),),
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
statement="Land schema changes in their own PR."),),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
@@ -311,3 +316,52 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
assert len(result["suppressed_topics"]) == 1
assert result["suppressed_topics"][0]["title"] == "design-system"
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
def test_rule_brief_carries_age_but_not_the_deep_fields():
"""The shape a SURFACED rule takes, and the reason it exists.
There were three hand-written copies of this dict and they had already
diverged — none carried the timestamps the model has always held, which is
why a rule written before the capability it duplicates was
indistinguishable at read time from one still doing work (note 3026).
"""
from scribe.services.rulebooks import rule_brief
out = rule_brief(fake_rule(
when_to_apply="before any git push",
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
))
assert out["when_to_apply"] == "before any git push"
assert out["tier"] == "always_on"
# A DATE, not a stamp: the question is "how old is this", and a full ISO
# string across the always-on set is ~2k characters of payload.
assert out["updated_at"] == "2026-06-01"
# The depth stays with get_rule — putting it in every listing is the bloat
# this milestone is about.
assert "why" not in out and "how_to_apply" not in out
def test_rule_brief_omits_keys_a_rule_has_no_value_for():
"""#2483: a null key reads as a capability the record has and isn't using,
which is a different claim from not having one."""
from scribe.services.rulebooks import rule_brief
out = rule_brief(fake_rule())
assert "when_to_apply" not in out
assert "arose_from_id" not in out
def test_an_unknown_tier_falls_back_to_binding():
"""The asymmetry that decides the direction: a rule that preloads when it
needn't costs context; a rule that quietly stops preloading costs the
behaviour it was written for. So a typo binds."""
from scribe.services.rulebooks import _valid_tier
assert _valid_tier("conditional") == "conditional"
assert _valid_tier("always_on") == "always_on"
assert _valid_tier("Conditional") == "always_on"
assert _valid_tier("") == "always_on"
assert _valid_tier("occasionally") == "always_on"