from sqlalchemy import BigInteger, Index, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import CreatedAtMixin, iso SURFACED = "surfaced" PULLED = "pulled" class RuleUsageEvent(Base, CreatedAtMixin): """One row per time a rule was SURFACED to the agent, or PULLED in full. The sibling `note_usage_events` has had since 2026-07, third in the line after `rule_embeddings` and `rule_versions` — and, like those, it exists because the rule side kept inheriting machinery built for notes and quietly getting the weaker version of it. WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness about a polymorphic column — 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 it is IDENTITY AT RESTORE. A note id and a rule id are different namespaces resolved through different maps, and `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 happened to take that number — telemetry that is not merely lost but wrong, and wrong in a way nothing downstream could detect. WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. 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 (#3311: 296 calls, zero zero-result, 100% clearing its threshold). `retrieval_logs` gives it scores; scores say what the ranker thought, never whether the hint landed. Without a pull counter no install can tune the arm from evidence, only from the shape of a histogram. Deliberately FK-FREE on `rule_id` and `user_id`, matching `note_usage_events`, `retrieval_logs` and `app_logs` — and diverging from `rule_versions`, which does carry FKs. The difference is what the row is FOR: a version is part of a rule's history and dies with it, while telemetry outlives the row it describes. Deleting a rule must not erase the evidence that it was surfaced forty times and opened never, because that evidence is precisely the case for having deleted it. Cells left deliberately empty (note #3163's step 3): no share ACL — rules have none of their own; no soft delete — nothing restores a telemetry row, and the table is append-only; no embedding — an event is not a document. """ __tablename__ = "rule_usage_events" # BigInteger throughout, where the note twin uses Integer. `rule_id` has to # be, since `rules.id` is BigInteger — and once one column is, matching the # rest costs nothing and keeps the row uniform. A high-churn append-only # telemetry table is a poor place to discover an id ceiling. id: Mapped[int] = mapped_column(BigInteger, primary_key=True) user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) rule_id: Mapped[int] = mapped_column(BigInteger, nullable=False) # 'surfaced' | 'pulled' event: Mapped[str] = mapped_column(Text, nullable=False) # Which surface produced it. A CONVENTION, not a fixed vocabulary, and the # note twin's comment explains why this one deliberately does not enumerate # its members: the previous such list went stale, naming a source nothing # wrote while omitting ones that existed, and a half-true enumeration reads # as authoritative in exactly the way that misleads (#2476). # `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the # authoritative list, and unlike a comment it cannot drift. # # The mcp_/rest_ prefix split is load-bearing here for the same reason it is # for notes, and more so: "is this rule dead weight?" is served by any pull, # but "did that injected hint land?" — the question this arm exists to # answer — is served by AGENT pulls only. Never aggregate across the prefix # without saying why. source: Mapped[str] = mapped_column(Text, nullable=False) __table_args__ = ( # Every readout is "these rule ids, split by event" — a covering # composite beats separate single-column indexes for it. Index("ix_rule_usage_rule_event", "rule_id", "event"), Index("ix_rule_usage_created_at", "created_at"), Index("ix_rule_usage_user_id", "user_id"), ) def to_dict(self) -> dict: return { "id": self.id, "created_at": iso(self.created_at), "user_id": self.user_id, "rule_id": self.rule_id, "event": self.event, "source": self.source, }