"""a rule can carry its own check — verify_with, expires_when, verified_at Revision ID: 0090 Revises: 0089 Create Date: 2026-08-27 A rulebook holds two kinds of row in one table. A NORM is a decision: it has no truth value, and it changes only when its author changes it — which they know they did. A CONSTRAINT is a fact about someone else's software: a runner's shell, a bot's config, a tool that exists. Nobody is present when that goes false. What goes stale in a rulebook is the constraints, not the norms — and a stale constraint keeps instructing every session that loads it, long after the world it described has moved. Three nullable columns, so a rule can say how to check itself: - `verify_with` — how to tell whether this is still true. A command, a path, a URL, a query. Prose is allowed; something runnable is better. - `expires_when` — the STATE under which it stops being true. Deliberately not a date: constraints do not expire on a schedule, they expire when the world underneath them moves. - `verified_at` — when the check last passed. NULL means never checked, and sorts FIRST in the sweep: unexamined outranks examined-long-ago. All three nullable and all three optional, because most rules should set none of them. A null `verify_with` is not an omission — it is the honest marker of "this one is a decision, and there is nothing to go and check." That signal only works if the field stays empty wherever it belongs empty. No CHECK constraint is involved. Nothing is backfilled: a migration cannot invent a check any more than 0088 could invent a trigger. """ import sqlalchemy as sa from alembic import op revision = "0090" down_revision = "0089" branch_labels = None depends_on = None def upgrade() -> None: op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True)) op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True)) op.add_column( "rules", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), ) # No index on (verify_with, verified_at). The sweep this exists for reads # an operator's whole rulebook — hundreds of rows, not millions — and runs # when a human asks for it, never on a request path. An index here would # be maintained on every rule write to serve a query that a sequential # scan answers instantly. def downgrade() -> None: op.drop_column("rules", "verified_at") op.drop_column("rules", "expires_when") op.drop_column("rules", "verify_with")