feat(cli): tapir report — Stage-0 usage gate query
ActiveWeeks computes per-user distinct active weeks (reads login_events UNION acts summary_actions) for the gate (VISION/ADR-016: usage in >=2 distinct weeks). Because the user-owned tables are FORCE RLS under a non-superuser owner, a single cross-user query is deny-all; instead it enumerates users from the un-RLS'd identity map and counts each inside withUser — no privilege escalation, no policy change. `tapir report` prints the per-user table and the pass/fail verdict (needs only TAPIR_DB_DSN). Pure formatter + store query are unit-tested, including the cross-table shared-week dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,8 @@ func main() {
|
|||||||
err = cmdServe(ctx, log)
|
err = cmdServe(ctx, log)
|
||||||
case "invite":
|
case "invite":
|
||||||
err = cmdInvite(ctx, os.Args[2:])
|
err = cmdInvite(ctx, os.Args[2:])
|
||||||
|
case "report":
|
||||||
|
err = runReport(ctx, os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -77,6 +79,7 @@ usage:
|
|||||||
tapir invite <email> mint an invitation link for a new user (host-side)
|
tapir invite <email> mint an invitation link for a new user (host-side)
|
||||||
tapir list [-limit N] list stored summaries, recent first
|
tapir list [-limit N] list stored summaries, recent first
|
||||||
tapir show <video-id> show one summary in full
|
tapir show <video-id> show one summary in full
|
||||||
|
tapir report Stage-0 usage gate: per-user distinct active weeks
|
||||||
|
|
||||||
configuration is via TAPIR_* environment variables (see .env.example).
|
configuration is via TAPIR_* environment variables (see .env.example).
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct
|
||||||
|
// weeks. The gate passes when any user reaches it.
|
||||||
|
const gateThreshold = 2
|
||||||
|
|
||||||
|
// runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads
|
||||||
|
// UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only
|
||||||
|
// TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself).
|
||||||
|
func runReport(ctx context.Context, _ []string) error {
|
||||||
|
dsn := os.Getenv(envDSN)
|
||||||
|
if dsn == "" {
|
||||||
|
return fmt.Errorf("%s is required", envDSN)
|
||||||
|
}
|
||||||
|
s, err := store.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
rows, err := s.ActiveWeeks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return formatReport(os.Stdout, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatReport renders the per-user week counts and the gate verdict. Pure: no DB,
|
||||||
|
// no env — so the layout and verdict logic are unit-testable without Postgres.
|
||||||
|
func formatReport(w io.Writer, rows []store.UserActiveWeeks) error {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
_, err := fmt.Fprintln(w, "no users yet")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||||
|
_, _ = fmt.Fprintln(tw, "USER\tNAME\tACTIVE_WEEKS\tGATE")
|
||||||
|
passed := false
|
||||||
|
for _, r := range rows {
|
||||||
|
gate := "-"
|
||||||
|
if r.ActiveWeeks >= gateThreshold {
|
||||||
|
gate = "PASS"
|
||||||
|
passed = true
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", r.UserID, orDash(r.DisplayName), r.ActiveWeeks, gate)
|
||||||
|
}
|
||||||
|
if err := tw.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
verdict := fmt.Sprintf("\nGate (usage in >= %d distinct weeks): NOT YET MET\n", gateThreshold)
|
||||||
|
if passed {
|
||||||
|
verdict = fmt.Sprintf("\nGate (usage in >= %d distinct weeks): PASSED\n", gateThreshold)
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprint(w, verdict)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
||||||
|
rows := []store.UserActiveWeeks{
|
||||||
|
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
|
||||||
|
{UserID: "user-b", DisplayName: "", ActiveWeeks: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, formatReport(&b, rows))
|
||||||
|
out := b.String()
|
||||||
|
|
||||||
|
require.Contains(t, out, "USER")
|
||||||
|
require.Contains(t, out, "ACTIVE_WEEKS")
|
||||||
|
require.Contains(t, out, "Ada")
|
||||||
|
require.Contains(t, out, "PASSED", "a user at >= 2 weeks passes the gate")
|
||||||
|
|
||||||
|
// The >=2 user is marked PASS; the 1-week user is not.
|
||||||
|
require.Contains(t, lineContaining(t, out, "user-a"), "PASS")
|
||||||
|
require.NotContains(t, lineContaining(t, out, "user-b"), "PASS")
|
||||||
|
require.Contains(t, lineContaining(t, out, "user-b"), "-", "no display name falls back to dash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatReportGateNotMet(t *testing.T) {
|
||||||
|
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, formatReport(&b, rows))
|
||||||
|
require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatReportEmpty(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, formatReport(&b, nil))
|
||||||
|
require.Contains(t, b.String(), "no users yet")
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"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.
|
||||||
|
func (s *Store) ActiveWeeks(ctx context.Context) ([]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)
|
||||||
|
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) (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
|
||||||
|
UNION
|
||||||
|
SELECT date_trunc('week', acted_at)
|
||||||
|
FROM summary_actions WHERE user_id = $1
|
||||||
|
)
|
||||||
|
SELECT count(DISTINCT wk) FROM weeks`, userID).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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedReportUser inserts a user + its identity mapping (the enumeration source
|
||||||
|
// ActiveWeeks reads). display_name is optional.
|
||||||
|
func seedReportUser(t *testing.T, p *pgxpool.Pool, userID, subject, name string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := p.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, display_name) VALUES ($1, NULLIF($2, ''))`, userID, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs is the gate-query proof.
|
||||||
|
// It seeds, with fixed timestamps in known ISO weeks:
|
||||||
|
// - user A: reads in week of Jan 5 and Jan 12, acts in week of Jan 12 (dup) and
|
||||||
|
// Jan 19 → the UNION across both tables collapses the shared week → 3 distinct.
|
||||||
|
// - user B: a single read in the week of Jan 5 → 1 distinct (below the gate).
|
||||||
|
//
|
||||||
|
// It verifies the count is correct, dedups the cross-table shared week, and orders
|
||||||
|
// most-active first.
|
||||||
|
func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p) // TRUNCATE ... users CASCADE also clears user_identities
|
||||||
|
|
||||||
|
seedReportUser(t, p, userA, "subject-a", "Ada")
|
||||||
|
seedReportUser(t, p, userB, "subject-b", "")
|
||||||
|
|
||||||
|
// Reads (login_events) — fixed dates in distinct ISO weeks.
|
||||||
|
_, err := p.Exec(ctx,
|
||||||
|
`INSERT INTO login_events (user_id, seen_at) VALUES
|
||||||
|
($1, '2026-01-05T09:00:00Z'),
|
||||||
|
($1, '2026-01-12T09:00:00Z'),
|
||||||
|
($2, '2026-01-05T09:00:00Z')`, userA, userB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Acts (summary_actions) — one in A's week-of-Jan-12 (shared with a read, must
|
||||||
|
// dedup) and one in a new week (Jan 19).
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO summary_actions (user_id, video_id, action, acted_at) VALUES
|
||||||
|
($1, 'vid-1', 'watched', '2026-01-12T18:00:00Z'),
|
||||||
|
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := s.ActiveWeeks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2, "both identity users must appear")
|
||||||
|
|
||||||
|
require.Equal(t, userA, got[0].UserID, "most-active user first")
|
||||||
|
require.Equal(t, "Ada", got[0].DisplayName)
|
||||||
|
require.Equal(t, 3, got[0].ActiveWeeks, "3 distinct weeks across reads+acts, shared week deduped")
|
||||||
|
|
||||||
|
require.Equal(t, userB, got[1].UserID)
|
||||||
|
require.Equal(t, 1, got[1].ActiveWeeks, "single read = 1 distinct week (below gate)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveWeeksEmptyWhenNoUsers: no identities → no rows (not an error).
|
||||||
|
func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
got, err := s.ActiveWeeks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user