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

`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:
2026-09-16 14:45:32 -04:00
co-authored by Claude Opus 5
parent 5ae60734bb
commit 07bdbf1647
10 changed files with 204 additions and 6 deletions
+10 -6
View File
@@ -246,7 +246,7 @@ async def get_rule(rule_id: int) -> dict:
async def create_rule(
topic_id: int, title: str, statement: str, when_to_apply: str = "",
topic_id: int, title: str, statement: str, when_to_apply: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False,
@@ -344,10 +344,14 @@ async def create_rule(
when_to_apply: WHEN this rule fires — the trigger, not the
instruction. State the moment or the material: "before any git
push", "when adding a value to a CHECK-gated column", "when a
release is being cut". Write it even though the parameter is
optional: it is how the rule is found
when it matters, and a rule nobody can place is a rule nobody
applies.
release is being cut". REQUIRED, and not as ceremony: nothing is
preloaded, so this is the whole of how the rule is found when it
matters and it is half of what the rule is EMBEDDED as, so a
rule without one is not merely hard to find, it is stored in a
different shape from every rule it competes with. Name the SYMPTOM
— the words someone would type while stuck — rather than the
category: "the CI job passed locally and fails on the runner with
a permission error" retrieves; "when touching CI config" does not.
This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone
@@ -411,7 +415,7 @@ async def create_rule(
async def create_project_rule(
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
project_id: int, statement: str, when_to_apply: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False,
+9
View File
@@ -345,6 +345,15 @@ async def create_project_rule(project_id: int):
statement = (data.get("statement") or "").strip()
if not statement:
return jsonify({"error": "statement is required"}), 400
# Checked here as well as in the service, only for the STATUS CODE: the
# service raises ValueError, which this route maps to 404 for "project not
# found", and a missing trigger is a 400. The service stays the guard —
# this is the door telling the truth about whose mistake it was.
if not (data.get("when_to_apply") or "").strip():
return jsonify({
"error": "when_to_apply is required: a rule with no trigger never "
"surfaces at the moment it applies."
}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
try:
rule = await rulebooks_svc.create_project_rule(
+51
View File
@@ -457,12 +457,44 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N
return data
def _require_trigger(when_to_apply: str | None) -> None:
"""A rule without a trigger is not a quiet rule — it is an unreachable one.
Nothing is preloaded, so `when_to_apply` is the whole of how a rule
arrives. It is also what the record is EMBEDDED as: `rule_document` builds
`{title}{trigger}` / `When to apply: {trigger}\\n\\n{statement}`, with
the trigger appearing twice so that purpose dominates a short vector. Drop
it and the document silently changes shape to title + statement, so the
same score means something different for that rule than for its
neighbours — and every bar and every rank in the system assumes one shape.
ENFORCED IN THE SERVICE, so both doors are covered: the MCP tools and the
frontend's fast path (`routes/rulebooks.py`) both land here, and a guard
written in one of them would leave the other able to create a rule that
never fires.
Deliberately NOT following `arose_from_id`, which the human door exempts
itself from on the stated grounds that 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 exactly as badly as it fails a session.
"""
if not (when_to_apply or "").strip():
raise ValueError(
"when_to_apply is required: a rule with no trigger never surfaces "
"at the moment it applies. Name that moment in the words a session "
"would actually be producing then — the command, the error, the "
"half-formed ask — not the category it belongs to."
)
async def create_rule(
topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
when_to_apply: str = "", arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "", kind: str = "rule",
) -> Rule:
_require_trigger(when_to_apply)
async with async_session() as session:
await _assert_topic_owned(session, topic_id, user_id)
rule = Rule(
@@ -498,6 +530,7 @@ async def create_project_rule(
rule in a rulebook topic is global. Topic_id is left NULL — the CHECK
constraint enforces exactly-one of (topic_id, project_id).
"""
_require_trigger(when_to_apply)
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
rule = Rule(
@@ -651,6 +684,17 @@ async def update_rule(
"verify_with", "expires_when",
}
check_before = rule.verify_with
# A create-time guard is worth nothing if an edit can undo it, and
# both doors can: `clear=["when_to_apply"]` from the MCP side, and a
# emptied form input normalised to None from the REST side. Checked
# AFTER the mutation instead, so it covers every route to an empty
# trigger including ones added later.
#
# Asked as "did this edit REMOVE a trigger", not "does one exist":
# a rule predating the guard has none, and refusing to save it would
# make the record permanently unfixable — freezing the exact rules
# that most need the edit.
trigger_before = (rule.when_to_apply or "").strip()
# Captured BEFORE anything is written, and as plain values — this has
# to survive the mutation below. A rule's history is the only record
# of what it used to say; the edit itself destroys that.
@@ -677,6 +721,13 @@ async def update_rule(
# rule wrongly vouched for costs the thing the sweep exists to catch.
if rule.verify_with != check_before:
rule.verified_at = None
if trigger_before and not (rule.when_to_apply or "").strip():
raise ValueError(
"when_to_apply cannot be cleared: it is how this rule arrives, "
"and it is half of what the rule is embedded as. Replace the "
"trigger with a better one rather than removing it — a rule "
"with none is not a quieter rule, it is an unreachable one."
)
# Same session as the edit, so the two commit together. The snapshot
# holds the OLD verify_with — the check that was in force when that
# wording was written — which is why it is taken before the loop and