feat(rules)!: a rule cannot be created, or edited into, having no trigger (#4099)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 33s
CI & Build / Python tests (push) Failing after 37s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 33s
CI & Build / Python tests (push) Failing after 37s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Build & push image (push) Skipped
`when_to_apply` is not metadata. `rule_document` embeds a rule as
`{title} — {trigger}` / `When to apply: {trigger}\n\n{statement}`, the trigger
appearing twice so purpose dominates a short vector — the shape note 2485
measured on snippets (a 0.153 top-to-second gap against 0.010–0.023 for
everything else). Without one the document silently becomes title + statement:
a DIFFERENT shape, ranked against a corpus it does not match, with nothing to
report it. Every bar and every rank in the system assumes one shape.
`create_preference` has refused an empty trigger since it shipped. The two rule
creators defaulted it to "" — so the shape was enforced for the record kind
that guides and optional for the kind that binds.
The guard lives in the SERVICE, because both doors reach it: the MCP tools and
the frontend's fast path in routes/rulebooks.py. Written in either alone, the
other could still create a rule that never fires. The route keeps a matching
check for the STATUS CODE only (400, not the 404 it maps ValueError to).
update_rule refuses to EMPTY an existing trigger, checked after the mutation so
it covers `clear=[...]`, an emptied form input, and any route added later.
Deliberately asked as "did this edit remove one" rather than "does one exist":
a rule predating the guard has none, and refusing to save it would freeze
precisely the unreachable records that most need fixing.
Deliberately not following arose_from_id, which the human door exempts itself
from because provenance is about auditing what the AGENT changed. That reasoning
does not reach this field — a missing trigger is not a missing explanation, it
is a rule that does not work, and it fails an operator as badly as a session.
15 test fixtures across 6 files were creating rules with no trigger. They now
pass one; that they did not is the point — curation is not a guarantee.
Step 1 of milestone 416 "Retrieval stops guessing a bar". First because every
later step assumes one document shape, and it is much cheaper to guarantee
before a corpus grows than to backfill after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -62,6 +62,7 @@ async def test_create_rule_passes_required_fields():
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
await create_rule(
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
topic_id=10, title="dev is home", statement="Work directly on dev",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
@@ -80,6 +81,7 @@ async def test_create_rule_blocked_by_duplicate_gate():
|
||||
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
out = await create_rule(topic_id=10, title="dev is home", statement="x")
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
assert out["duplicate"] is True
|
||||
assert out["existing_id"] == 47
|
||||
assert "update_rule" in out["message"]
|
||||
@@ -95,6 +97,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
|
||||
_plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
assert out["id"] == 5
|
||||
find_mock.assert_not_called()
|
||||
|
||||
@@ -245,6 +248,7 @@ async def test_create_project_rule_passes_required_fields():
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
project_id=42,
|
||||
statement="Always run migrations through alembic, not raw SQL.",
|
||||
why="audit trail",
|
||||
@@ -263,6 +267,7 @@ async def test_create_project_rule_derives_title_from_statement():
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
project_id=42,
|
||||
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
|
||||
)
|
||||
@@ -278,6 +283,7 @@ async def test_create_project_rule_uses_explicit_title_when_given():
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
project_id=42,
|
||||
statement="anything",
|
||||
title="no auto-docstrings",
|
||||
|
||||
Reference in New Issue
Block a user