Files
tapir/internal/adapters/store/report.go
T
mathiasandClaude Opus 4.8 36dd182fb5
CI / Build & Import (push) Successful in 11s
CI / Lint / Test / Vet (push) Successful in 10s
feat(report): Stage-0 usage gate counts from a baseline date (default 2026-06-11)
The return-usage gate (ADR-016) counted distinct active weeks over ALL history,
so pre-launch noise — testing churn and the period the pilot sat blocked on zero
summaries — would inflate the signal. Add a baseline: ActiveWeeks(ctx, since)
filters login_events + summary_actions to seen_at/acted_at >= since. The report
command sets it to TAPIR_USAGE_GATE_START (YYYY-MM-DD, default 2026-06-11 — the
morning the pilot was unblocked) and prints the baseline. The gate now measures
whether users RETURN once it genuinely works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:52:15 +02:00

118 lines
4.3 KiB
Go

package store
import (
"context"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
// UserActiveWeeks is one row of the Stage-0 gate report: how many DISTINCT
// calendar weeks a user was active in, counting reads (login_events) AND acts
// (summary_actions) together. The gate (VISION/ADR-016) passes when any user
// reaches ActiveWeeks >= 2.
type UserActiveWeeks struct {
UserID string
DisplayName string
ActiveWeeks int
}
// ActiveWeeks computes per-user distinct-active-weeks for the gate report, most
// active first.
//
// Why per-user iteration rather than one cross-user GROUP BY: the user-owned
// tables are FORCE RLS (migration 003/010) and the production role is a non-
// superuser owner, so a single un-scoped query sees nothing (deny-all). Instead we
// enumerate users from the deliberately un-RLS'd identity map (user_identities,
// migration 004) and count each user's weeks inside withUser, where the GUC scopes
// login_events + summary_actions to that user. No privilege escalation, no policy
// change — the same isolation seam every other read flows through.
//
// Scope note: the enumeration covers users with a Dex identity (the web users the
// gate is about). A CLI-only user created by the store sink without an identity
// row would not appear — out of scope for this gate.
// ActiveWeeks counts each user's distinct active weeks from `since` onward. A zero
// `since` means no lower bound (count all history). The Stage-0 gate baseline is
// set by the caller (the report command) to the date real usage tracking began,
// so pre-launch noise — testing, the period the pilot was blocked — does not count
// toward the return-usage signal (ADR-016).
func (s *Store) ActiveWeeks(ctx context.Context, since time.Time) ([]UserActiveWeeks, error) {
userIDs, err := s.identityUserIDs(ctx)
if err != nil {
return nil, err
}
out := make([]UserActiveWeeks, 0, len(userIDs))
for _, uid := range userIDs {
row, err := s.activeWeeksFor(ctx, uid, since)
if err != nil {
return nil, err
}
out = append(out, row)
}
// Most active first; user_id as a stable tie-break for deterministic output.
sort.SliceStable(out, func(i, j int) bool {
if out[i].ActiveWeeks != out[j].ActiveWeeks {
return out[i].ActiveWeeks > out[j].ActiveWeeks
}
return out[i].UserID < out[j].UserID
})
return out, nil
}
// identityUserIDs lists every tapir user_id from the un-RLS'd identity map. It
// runs directly on the pool (no withUser): user_identities carries no user data
// and is intentionally not RLS-enabled, so it is the one table that can be read
// pre-scope to discover who exists.
func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT user_id FROM user_identities ORDER BY user_id`)
if err != nil {
return nil, fmt.Errorf("store: list identity users: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan identity user: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate identity users: %w", err)
}
return ids, nil
}
// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and
// reads their display name, RLS-scoped via withUser. The UNION dedups a week that
// has both a login and an action so it counts once.
func (s *Store) activeWeeksFor(ctx context.Context, userID string, since time.Time) (UserActiveWeeks, error) {
res := UserActiveWeeks{UserID: userID}
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx,
`WITH weeks AS (
SELECT date_trunc('week', seen_at) AS wk
FROM login_events WHERE user_id = $1 AND seen_at >= $2
UNION
SELECT date_trunc('week', acted_at)
FROM summary_actions WHERE user_id = $1 AND acted_at >= $2
)
SELECT count(DISTINCT wk) FROM weeks`, userID, since).Scan(&res.ActiveWeeks); err != nil {
return fmt.Errorf("store: count active weeks: %w", err)
}
if err := tx.QueryRow(ctx,
`SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&res.DisplayName); err != nil {
return fmt.Errorf("store: read display name: %w", err)
}
return nil
}); err != nil {
return UserActiveWeeks{}, err
}
return res, nil
}