Files
FabledScribe/tests/test_routes_rulebooks.py
T
bvandeusenandClaude Opus 5 0e10f6bb8a
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality
as the goal was to not have it at all since it didn't seem to work as
expected."

Unconditional preload had three failures the retrieval arms do not. It could
not be MEASURED — a resident rule is in the context whether or not it
mattered, so nothing distinguished "this governed the act" from "this was
scenery", and it was the one surface structurally exempt from the scoreboard
judging every other. It was SUMMARISED AWAY by compaction while the session
went on believing it held the rules. And it CROWDED OUT the few rules that
applied with the thirty that did not.

WHAT GOES

Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier,
rulebooks.always_on, and project_rulebook_exclusions — a table recording a
project's opt-out of something that no longer binds it unasked.

Tools: list_always_on_rules, exclude_always_on_rulebook,
include_always_on_rulebook. Service: the same three plus rules_etag_for,
_valid_tier and the whole etag family. The SessionStart preload and the
write-path staleness arm go with them: nothing is resident, so nothing can
have drifted since a session loaded it.

THREE CALLS WORTH REVIEWING

enter_project got NARROWER, not wider. Its filter was `always_on OR
area-tagged`; dropping the tier arm leaves the deterministic half, so a
project with no canonical-tagged Systems gets no bulk rules and reaches them
by retrieval instead. Dropping the whole clause would have made that payload
bigger than the preload this milestone deletes.

Backups import tolerantly. A pre-394 archive carries tier, always_on and the
retired inception choice; none is read, and the exclusion key is DROPPED
rather than remapped, because restoring it would write data that
validate_inception now rejects as unknown.

The migration is irreversible in the way that matters and says so: downgrade
recreates the columns at their defaults and cannot restore which rules were
always-on. A value invented to fill a hole is not a measurement.

THE INSTRUCTION SURFACES SAY THE HARDER THING

Deleting "call list_always_on_rules()" is easy; replacing it is not, because
the new model asks a session to trust something it cannot see. All three
surfaces now say a session holds nothing, that rules arrive when work matches
them, and — the half that got dangerous — that "no rule arrived" means
"nothing matched", never "there is no rule". Under residency an empty session
was rare and suspicious; it is now the ordinary state of most turns, so
reading it as permission is wrong on nearly every turn rather than
occasionally. That is #3720's defect at session scale.

test_instruction_surfaces_agree is repointed rather than retired: its two
halves collapsed into one instruction, and it gains a guard that every
surface states what absence means. _INSTRUCTIONS is back at 1999/2000 —
the inception clause paid for the longer HOW line.

UI (rule 27, and the opportunity step 8 named)

The tier selector is gone, and what replaces it is the point: `when_to_apply`
is now the field that decides whether a rule is ever seen, so the editor
marks it required, warns while it is empty, and both rule lists badge a
trigger-less rule "never surfaces". A rule without one is not quiet, it is
unreachable.

TESTS

Two files deleted outright — test_rules_etag.py and test_inception_rules.py
tested subsystems that no longer exist. Elsewhere obsolete cases were removed
and the rest repointed. One deserves naming: the wiring test asserted the act
arms pass no `tier`, which had become an assertion that could not fail. It is
repointed onto `kind`, which does still exist and where the same claim is
live — a preference must reach a write exactly as a rule does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
2026-09-11 16:22:17 -04:00

133 lines
5.3 KiB
Python

"""Route-level tests for the rulebooks blueprint.
Mirrors the structural-tests pattern in tests/test_events_routes.py:
covers blueprint registration, callable handlers, and service-signature
contracts. Full HTTP integration requires a live DB and auth machinery
that the unit-test environment doesn't provide.
"""
import inspect
def test_rulebooks_blueprint_registered():
from scribe.routes.rulebooks import rulebooks_bp
assert rulebooks_bp.name == "rulebooks"
assert rulebooks_bp.url_prefix == "/api"
def test_rulebooks_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "rulebooks" in app.blueprints
def test_rulebook_handlers_callable():
from scribe.routes import rulebooks as rb_routes
for name in (
"list_rulebooks", "create_rulebook", "get_rulebook",
"update_rulebook", "delete_rulebook",
):
assert callable(getattr(rb_routes, name))
def test_topic_handlers_callable():
from scribe.routes import rulebooks as rb_routes
for name in (
"list_topics", "create_topic", "update_topic", "delete_topic",
):
assert callable(getattr(rb_routes, name))
def test_service_signatures_require_user_id():
"""Routes must call services with user_id — verify the contract."""
from scribe.services import rulebooks as svc
for fn_name in (
"create_rulebook", "list_rulebooks", "get_rulebook",
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
"create_rule", "create_project_rule", "rule_detail",
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
"list_rules",
"get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
):
sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
def test_rule_model_carries_project_id_and_topic_id_nullable():
"""Migration 0059 made topic_id nullable and added project_id."""
from scribe.models.rulebook import Rule
assert "project_id" in Rule.__table__.columns
assert Rule.__table__.columns["topic_id"].nullable is True
assert Rule.__table__.columns["project_id"].nullable is True
def test_create_project_rule_route_exists():
"""POST /api/projects/<id>/rules — the frontend fast-path endpoint."""
from scribe.routes import rulebooks as rb_routes
assert callable(getattr(rb_routes, "create_project_rule"))
def test_suppression_route_handlers_exist():
"""The 4 suppression endpoint handlers are registered as Python callables."""
from scribe.routes import rulebooks as rb_routes
for name in (
"suppress_project_rule", "unsuppress_project_rule",
"suppress_project_topic", "unsuppress_project_topic",
):
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
def test_suppression_association_tables_declared():
"""Migration 0060 created two new association tables; the models module
must declare matching Table() objects so the rest of the service layer
can reference them via .c.<column>."""
from scribe.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
tbl = getattr(rb_models, tbl_name, None)
assert tbl is not None, f"models.rulebook missing {tbl_name}"
cols = {c.name for c in tbl.columns}
assert "project_id" in cols
assert "rule_id" in cols or "topic_id" in cols
def test_rule_and_subscription_handlers_callable():
from scribe.routes import rulebooks as rb_routes
for name in (
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_project_rules",
# The typed edges — both doors carry them (rule 33).
"relate_rules", "unrelate_rules",
):
assert callable(getattr(rb_routes, name))
def test_the_rule_list_zero_fills_usage_on_every_row():
"""Milestone 333 step 5, asserted the only way this harness allows.
There is no live-HTTP fixture here (see this module's docstring), so this
reads the handler's source. What it can still prove is the property that
gets forgotten: the route must attach the key to EVERY row, zero-filled,
rather than only to rows that happen to have events. Every rule on every
existing install predates `rule_usage_events`, so a route that only
attached the key when it found something would leave the badge component
reading `undefined` on almost every row — and the difference between "no
events" and "no field" is exactly the distinction #2663 is about.
"""
import inspect
from scribe.routes import rulebooks as rb_routes
src = inspect.getsource(rb_routes.list_rules)
assert "usage_for_rules" in src, "the rule list does not read usage at all"
assert "empty_rule_usage()" in src, (
"the rule list does not zero-fill — a rule with no events would come "
"back without the key rather than with an empty one"
)