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
91 lines
4.0 KiB
Python
91 lines
4.0 KiB
Python
"""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")
|