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 }