feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
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

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
This commit is contained in:
2026-09-11 16:22:17 -04:00
co-authored by Claude Opus 5
parent 18e3cf9f2f
commit 0e10f6bb8a
35 changed files with 388 additions and 939 deletions
+20 -24
View File
@@ -10,7 +10,6 @@ import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import canonical_systems as canonical_svc
@@ -31,12 +30,12 @@ async def seeded():
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
await s.commit()
# Two ordinary rulebooks. One was flagged always-on until milestone 394
# removed the tier; a rulebook now reaches a project only by subscription,
# so what used to be "binds automatically" and "binds if you opt in" are
# the same kind of thing.
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
async with async_session() as s:
rb = await s.get(Rulebook, always.id)
rb.always_on = True
await s.commit()
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
@@ -48,20 +47,20 @@ async def seeded():
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
# Undecided: nothing binds, because nothing has been subscribed. Before
# milestone 394 the always-on rulebook bound here without being asked for,
# and this assertion read the other way round.
defaults = await inception_svc.current_defaults(owner, pid)
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
[seeded["always"], seeded["other"]])
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["always"]],
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"seed_systems": True,
})
assert out["effects"]["excluded"] == [seeded["always"]]
assert out["effects"]["subscribed"] == [seeded["other"]]
catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
@@ -69,35 +68,32 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
seeded_systems = await systems_svc.list_systems(owner, pid)
assert all(s.canonical_id is not None for s in seeded_systems)
# The exclusion is total: the project's always-on set is empty, the
# departure is named, the subscription binds.
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
# The subscription is what binds, and it is the ONLY thing that does —
# the unsubscribed rulebook contributes nothing even though it used to
# bind every project by default.
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
# The record, written last, says why.
async with async_session() as s:
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
# Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
with pytest.raises(ValueError, match="not always-on"):
# A subscription to a rulebook that is not yours is refused BEFORE any
# effect lands — the seed must not happen on a decision that fails.
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
"subscribe_rulebooks": [999999], "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):