feat(rules): a preference is a rule that does not bind (#3849 step 1)
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m11s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m11s
CI & Build / Build & push image (push) Skipped
Adds `kind` to rules — `rule` binds, `preference` is how the operator wants work done. One column, because the two differ in exactly one dimension and everything else a preference needs already lives on `rules`: a trigger column, a trigger-dominated embedding document, ownership-scoped search, three retrieval arms with telemetry, typed relations, and versioning. Defaults to `rule`, so nothing changes force on upgrade — 0088's argument for `tier`, unchanged. `rule_versions` gets the column too, and that half is not bookkeeping. `record_if_changed` decides whether an edit deserves a snapshot by comparing the fields a version carries, so a field absent from SNAPSHOT_FIELDS is a field whose change records no history at all. Without it, turning a rule into a preference — the moment something stops binding, and the single most consequential edit either kind can undergo — would leave the history silent. Backup carries it through all four seams. A missed one would have restored every preference as a rule, quietly. Guarded on real Postgres in three halves: a preference writes, a typo is refused (without which every other assertion would pass against a table whose CHECK had been dropped), and a row written with no kind reads back as `rule` — the migration's whole safety claim, asserted rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""rules gain a `kind` — a preference is a rule that does not bind (#3849)
|
||||
|
||||
Revision ID: 0098
|
||||
Revises: 0097
|
||||
Create Date: 2026-09-10
|
||||
|
||||
A rule says what must be followed. There was no way to record the other
|
||||
thing the operator kept writing rules for: **how they want work done**.
|
||||
Several rules in a mature rulebook are not really rules — pace this kind of
|
||||
debugging, hand off an action with its reason, end a finding with an offer.
|
||||
Ignoring one of those does not break anything or cross a boundary; it costs
|
||||
consistency. They were written as rules because a rule was the only record
|
||||
that is global, keyed to a situation, and delivered when that situation
|
||||
arrives.
|
||||
|
||||
So: `kind`. `rule` binds. `preference` describes how this person wants it
|
||||
done, and — the part that makes it its own kind rather than a softer label —
|
||||
**it is expected to change as the work teaches it.** The agent updates a
|
||||
preference in the ordinary course of working, where a rule waits for its
|
||||
author.
|
||||
|
||||
WHY A COLUMN AND NOT A TABLE. Everything a preference needs already exists on
|
||||
`rules` and nowhere else: `when_to_apply` as a real column, a
|
||||
trigger-dominated embedding document, ownership-scoped search that is
|
||||
deliberately not filtered to one project, three retrieval arms with per-arm
|
||||
telemetry, typed relations, and versioning. The two differ in exactly one
|
||||
dimension — force — and one dimension is a field.
|
||||
|
||||
The drift machinery is the decisive part. `rule_versions` already snapshots
|
||||
every write, and `rule_relations.overrides` already models "this supersedes
|
||||
that for its scope". A separate table would have rebuilt both, and moving
|
||||
existing rows into it would have changed their ids — silently invalidating
|
||||
every record in the corpus that cites a rule by number.
|
||||
|
||||
DEFAULTS TO `rule`, so this migration changes NOTHING about what binds. An
|
||||
install upgrades and every existing row keeps the force it had. That is the
|
||||
same reasoning 0088 used for `tier`, and it is the reason both are safe to
|
||||
apply without reading the data first.
|
||||
|
||||
The CHECK is created with the column (rule 36: there is no prior constraint,
|
||||
so the pair is created together — a value added to it LATER does DROP + ADD
|
||||
in one migration).
|
||||
|
||||
`rule_versions` GETS THE COLUMN TOO, and that half is not bookkeeping.
|
||||
`record_if_changed` decides whether an edit is worth a snapshot by comparing
|
||||
the fields a version carries; a field a version does not carry is a field
|
||||
whose change records NO HISTORY AT ALL. Without this, turning a rule into a
|
||||
preference — the single most consequential edit either kind can undergo,
|
||||
because it is the moment something stops binding — would leave the history
|
||||
silent. Nullable and no CHECK there, matching `tier`: a version is a record
|
||||
of what was, and a constraint on it would refuse to store a kind later
|
||||
dropped from the live whitelist.
|
||||
|
||||
Downgrade drops all three. Nothing reads `kind` for correctness; a rule that
|
||||
was a preference simply becomes a rule again, which is the safe direction.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0098"
|
||||
down_revision = "0097"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Kept in one place so upgrade and the CHECK agree by construction — 0088's
|
||||
# idiom, for the same reason.
|
||||
_KINDS = ("rule", "preference")
|
||||
|
||||
|
||||
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("kind", sa.Text(), nullable=False, server_default="rule"),
|
||||
)
|
||||
op.create_check_constraint("ck_rules_kind", "rules", _in_list("kind", _KINDS))
|
||||
# Nullable, no CHECK — see the module docstring. A version written before
|
||||
# this migration genuinely does not know, and NULL there means "not
|
||||
# recorded", never "was a rule".
|
||||
op.add_column("rule_versions", sa.Column("kind", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rule_versions", "kind")
|
||||
op.drop_constraint("ck_rules_kind", "rules", type_="check")
|
||||
op.drop_column("rules", "kind")
|
||||
Reference in New Issue
Block a user