T11.1: UsageEvent model + migration (CR-019)

New usage_events table, separate from audit_log - see the model docstring
for why. Verified: applies and downgrades cleanly on SQLite, and the
postgresql-dialect --sql render has no risky defaults (the BL-027 class
of defect). No app wiring yet - that's T11.2.
This commit is contained in:
2026-09-23 11:23:14 -07:00
parent 75ac930d0c
commit 0652fa732d
2 changed files with 85 additions and 0 deletions

View File

@@ -337,6 +337,45 @@ class AuditLog(Base):
}
class UsageEvent(Base):
"""CR-019: append-only record of who used the suite, when, and which tool —
navigation/session activity, not business mutations. Deliberately a SEPARATE
table from AuditLog rather than a new `action` value there: AuditLog answers
"who changed what" and is read by people auditing a specific record's
history; mixing in a `page_open` row for every authenticated page load
would make that trail noisy for its existing purpose. This table answers a
different question — "who is active, and on what" — and CR-019's admin
console reads from here, not from AuditLog.
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
is later removed should still show up in historical activity rather than
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
`is_active=False` — it never deletes a row — so this is defensive symmetry
rather than a live concern today.
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
nothing purges rows written here; that is a deliberate product decision,
not an oversight to fix later."""
__tablename__ = "usage_events"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
username: Mapped[str] = mapped_column(String(200), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
# creator | wizard | field_view | dashboard | admin | directory | ...
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
# page_open | login
event: Mapped[str] = mapped_column(String(40), default="", index=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "username": self.username,
"project_id": self.project_id, "tool": self.tool, "event": self.event,
"detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like