"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1) Revision ID: 0094 Revises: 0093 Create Date: 2026-09-02 The sibling `note_usage_events` has had since 0071, and the third rule-side table to arrive after `rule_embeddings` and `rule_versions` — each one added because the rule side kept inheriting machinery built for notes and getting the weaker version of it. WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval surface in Scribe whose usefulness cannot be observed, and — not coincidentally — the only one that has never declined to fire. Over 30 days it took 296 calls, returned something on every one, and cleared its threshold 100% of the time, while every other surface declines most of the time (#3311). That is either a perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs` cannot tell them apart: it records what the ranker scored, never whether the hint was any use. WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific fields and the aggregate readout is the same shape, which is the strongest case for sharing that note #3163 admits. What decides against it is identity at RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map` and drops what does not resolve. A rule id parked in that column would come back from a backup silently reattached to whatever note took that number — telemetry not merely lost but wrong, and wrong in a way nothing downstream could detect. `rule_versions` made the same call for the same reason. FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs and app_logs — and deliberately unlike `rule_versions`, which does carry FKs. The difference is what the row is for: a version belongs to a rule's history and dies with it; telemetry outlives the row it describes. Deleting a rule must not erase the evidence that it was surfaced forty times and opened never, since that evidence is exactly the case for having deleted it. No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to a column that is already gated; it does not require gating one that never was, and a two-member enum whose members are written by two functions in one module is not where that discipline earns its cost. Downgrade drops the table outright. The data is purely observational — nothing reads it for correctness, so losing it costs history and no behaviour. """ from alembic import op import sqlalchemy as sa revision = "0094" down_revision = "0093" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "rule_usage_events", # BigInteger throughout where the note twin uses Integer: rules.id is # BigInteger, so rule_id must be, and a high-churn append-only table is # a poor place to discover an id ceiling. sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column("user_id", sa.BigInteger(), nullable=True), sa.Column("rule_id", sa.BigInteger(), nullable=False), sa.Column("event", sa.Text(), nullable=False), sa.Column("source", sa.Text(), nullable=False), ) # Every readout is "these rule ids, split by event", so the composite is the # one that actually gets used; the others serve pruning and per-user views. op.create_index( "ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"] ) op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"]) op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"]) def downgrade() -> None: op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events") op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events") op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events") op.drop_table("rule_usage_events")