carlos / platform Public

console: audit table — the store remembers what owners did

Additive migration (audit table), RecordEvent/Events newest-first with a
cap. Slice 3 groundwork: promote/rollback/canary handlers will record
here and the app page grows a Recent-activity section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e22284c07f66af48eb0f72ec15553866e123a25c parent 8c15d9f
2 files changed, +86 −0
  • internal/console/store.go +48 −0
  • internal/console/store_test.go +38 −0
diff --git a/internal/console/store.go b/internal/console/store.go
index 6cbfcbe2f..47ab808a7 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -117,6 +117,14 @@ func (s *Store) migrate() error {
app TEXT PRIMARY KEY,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app TEXT NOT NULL,
+ address TEXT NOT NULL,
+ action TEXT NOT NULL,
+ detail TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -356,6 +364,46 @@ func (s *Store) TakeConfirmation(rawToken string, now time.Time) (string, bool)
return addr, true
}
+// ---- audit (recent activity) ------------------------------------------------
+
+// Event is one audit row: something an owner did to an app through the
+// console. The audit is the console's own record of its own mutations —
+// platform state stays in the registry and the bucket, and promotions made
+// with the CLI never appear here.
+type Event struct {
+ App, Address, Action, Detail string
+ CreatedAt time.Time
+}
+
+// RecordEvent appends one audit row. Callers treat it as best-effort:
+// losing an audit line must never fail the action it records.
+func (s *Store) RecordEvent(app, address, action, detail string, now time.Time) error {
+ _, err := s.db.Exec(`INSERT INTO audit (app, address, action, detail, created_at)
+VALUES (?, ?, ?, ?, ?)`, app, address, action, detail, now.Unix())
+ return err
+}
+
+// Events returns app's audit rows, newest first, capped at limit.
+func (s *Store) Events(app string, limit int) ([]Event, error) {
+ rows, err := s.db.Query(`SELECT app, address, action, detail, created_at
+FROM audit WHERE app = ? ORDER BY id DESC LIMIT ?`, app, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Event
+ for rows.Next() {
+ var e Event
+ var ts int64
+ if err := rows.Scan(&e.App, &e.Address, &e.Action, &e.Detail, &ts); err != nil {
+ return nil, err
+ }
+ e.CreatedAt = time.Unix(ts, 0).UTC()
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
// ---- keys -------------------------------------------------------------------
// SigningKey returns the console's long-lived envelope-signing key (ECDSA
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index 6ec1a8e0d..0e2b4b142 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -227,6 +227,44 @@ func TestReopenMigrationIdempotent(t *testing.T) {
}
}
+func TestAuditEvents(t *testing.T) {
+ s := openTestStore(t)
+ base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ for i, ev := range []Event{
+ {App: "kass", Address: "paul@keymail.dev", Action: "promote", Detail: "promoted v1 to stable"},
+ {App: "kass", Address: "paul@keymail.dev", Action: "canary-create", Detail: "created canary rc at v2"},
+ {App: "aria", Address: "bob@keymail.dev", Action: "promote", Detail: "promoted z1 to stable"},
+ } {
+ if err := s.RecordEvent(ev.App, ev.Address, ev.Action, ev.Detail, base.Add(time.Duration(i)*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ events, err := s.Events("kass", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("Events(kass) = %+v, want 2 rows", events)
+ }
+ // Newest first, and no cross-app leakage.
+ if events[0].Action != "canary-create" || events[1].Action != "promote" {
+ t.Fatalf("order = %s, %s — want canary-create first", events[0].Action, events[1].Action)
+ }
+ if events[0].Address != "paul@keymail.dev" || events[0].Detail != "created canary rc at v2" {
+ t.Fatalf("event = %+v", events[0])
+ }
+ if !events[0].CreatedAt.Equal(base.Add(time.Minute)) {
+ t.Fatalf("CreatedAt = %v, want %v", events[0].CreatedAt, base.Add(time.Minute))
+ }
+ // The cap trims oldest-first.
+ if events, _ := s.Events("kass", 1); len(events) != 1 || events[0].Action != "canary-create" {
+ t.Fatalf("Events(kass, 1) = %+v", events)
+ }
+ if events, _ := s.Events("ghost", 10); len(events) != 0 {
+ t.Fatalf("Events(ghost) = %+v, want empty", events)
+ }
+}
+
func TestCreateAppClaimOnce(t *testing.T) {
s := openTestStore(t)
now := time.Now()