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:
@@ -0,0 +1,119 @@
|
||||
"""A rule cannot be created, or edited into, having no trigger (#4099).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
`when_to_apply` is not metadata. `rule_document` embeds a rule as
|
||||
`{title} — {trigger}` / `When to apply: {trigger}\n\n{statement}`, with the
|
||||
trigger appearing TWICE so purpose dominates a short vector — the shape note
|
||||
2485 measured. Drop the trigger and 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 always refused an empty trigger. `create_rule` and
|
||||
`create_project_rule` defaulted it to `""` — so the shape was enforced for the
|
||||
record kind that merely guides and optional for the kind that binds.
|
||||
|
||||
WHAT THIS PINS
|
||||
|
||||
The guard lives in the SERVICE, because both doors reach it: the MCP tools and
|
||||
the frontend's fast path in `routes/rulebooks.py`. A guard written in either
|
||||
one alone would leave the other able to create a rule that never fires, which
|
||||
is the failure mode this whole change exists to remove.
|
||||
|
||||
These call the service directly and assert on the refusal, so an empty guard
|
||||
cannot make them pass by accident (rule 167).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
TRIGGER_RE = re.compile(r"when_to_apply", re.I)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
|
||||
def test_a_rulebook_rule_needs_a_trigger(blank):
|
||||
"""Whitespace is not a trigger — it embeds exactly like an empty one."""
|
||||
with pytest.raises(ValueError, match=TRIGGER_RE):
|
||||
rulebooks_svc._require_trigger(blank)
|
||||
|
||||
|
||||
def test_a_real_trigger_passes():
|
||||
"""The guard has to let the normal case through, or the parametrised
|
||||
refusals above would pass for a guard that rejects everything."""
|
||||
assert rulebooks_svc._require_trigger("before any git push") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_refuses_before_it_touches_the_database():
|
||||
"""The guard runs ahead of the ownership check, so a missing trigger is
|
||||
reported as such rather than as whatever the session lookup says. A bogus
|
||||
topic_id proves no database was reached: were the guard absent, this would
|
||||
fail on the topic instead, with a different message."""
|
||||
with pytest.raises(ValueError, match=TRIGGER_RE):
|
||||
await rulebooks_svc.create_rule(
|
||||
topic_id=999_999, user_id=1, title="t", statement="s",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rule_refuses_too():
|
||||
"""The second door into the same defect. Named separately because the two
|
||||
functions are separate code paths that have drifted apart before."""
|
||||
with pytest.raises(ValueError, match=TRIGGER_RE):
|
||||
await rulebooks_svc.create_project_rule(
|
||||
project_id=999_999, user_id=1, title="t", statement="s",
|
||||
)
|
||||
|
||||
|
||||
def test_both_creators_actually_call_the_guard():
|
||||
"""Structural, and deliberately not satisfied by the behavioural tests
|
||||
above: those would still pass if a future edit inlined a second copy of
|
||||
the check. One definition is the point — a rule shape enforced in two
|
||||
places is a rule shape that will be enforced differently in two places."""
|
||||
import inspect
|
||||
|
||||
for fn in (rulebooks_svc.create_rule, rulebooks_svc.create_project_rule):
|
||||
src = inspect.getsource(fn)
|
||||
assert "_require_trigger(when_to_apply)" in src, (
|
||||
f"{fn.__name__} no longer delegates to the shared guard"
|
||||
)
|
||||
|
||||
|
||||
def test_update_cannot_empty_a_trigger_that_exists():
|
||||
"""The create guard is worth nothing if an edit can undo it, and both
|
||||
doors can try: `clear=['when_to_apply']` from the MCP side and an emptied
|
||||
form input from the REST side.
|
||||
|
||||
Asserted on the source because the check sits mid-transaction, after the
|
||||
mutation and before the commit — reaching it needs a database. What must
|
||||
not silently vanish is the pairing: the before-value captured, and the
|
||||
raise that reads it.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(rulebooks_svc.update_rule)
|
||||
assert "trigger_before" in src, "the pre-edit trigger is no longer captured"
|
||||
assert re.search(r"if trigger_before and not", src), (
|
||||
"update_rule no longer refuses to empty an existing trigger"
|
||||
)
|
||||
|
||||
|
||||
def test_a_rule_that_never_had_one_is_still_editable():
|
||||
"""The deliberate asymmetry, and the reason the check asks 'did this edit
|
||||
REMOVE a trigger' rather than 'does one exist'.
|
||||
|
||||
A rule predating the guard has no trigger. Refusing to save it would
|
||||
freeze exactly the records that most need fixing — the unreachable ones —
|
||||
so the guard must not fire when there was nothing to remove.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(rulebooks_svc.update_rule)
|
||||
assert "trigger_before and not" in src, (
|
||||
"the guard no longer keys on the BEFORE value, so a legacy rule with "
|
||||
"no trigger can no longer be edited at all"
|
||||
)
|
||||
Reference in New Issue
Block a user