"""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")