"""a rule can carry its own check — verify_with, expires_when, verified_at (milestone 312 step 1) 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. Milestone 307's rulebook audit found nine stale sites. Every one was a constraint; not one norm had rotted. One of them had been telling every session to skip database-backed tests for weeks while the integration lane sat green in the workflow. 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, so rule 36 does not apply here. 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")