Data Flow: westside-ops

arch-dataflow-westside-ops Architecture

architecture active westside-ops

Data Flow: westside-ops

Diagram

Flow 1: First login

Flow 2: Operator cohort task (example — copy all Kings parent emails)

Components

Participant Purpose Connection notes
Marcus (phone) Operator using the tool iOS Safari + Tailscale client. First-time install is ~3 minutes; after that, transparent.
Tailscale tailnet Network-layer gate <code>tailscale.com/expose: "true"</code> — private to tailnet members only. Not a public funnel.
westside-ops Ingress Tailscale ingress resource routing to the Service Standard <code>ingressClassName: tailscale</code>, <code>defaultBackend</code> points at <code>westside-ops:8501</code>
Streamlit pod App runtime Single replica. Uses <code>streamlit-keycloak</code> (or similar) for OIDC. Session state is in-memory per pod — pod restart = re-login.
Keycloak (westside realm) OIDC identity provider New client <code>westside-ops</code>, PKCE flow, reuses existing realm. Marcus's existing account + new <code>westside-ops-user</code> role.
basketball-api Postgres Data source (read-only) Cross-namespace Service call <code>postgres.basketball-api.svc.cluster.local:5432</code>. Connection made as <code>westside_ops_reader</code> role with 14-table GRANT allowlist.

Key Decisions

  • OIDC flow is standard PKCE, not a custom auth layer. Streamlit doesn't ship OIDC out of the box, but the community streamlit-keycloak package (and similar alternatives) wire it up in ~30 lines of config. Using a standard PKCE flow means Marcus's Keycloak account (the same one he uses for westside-app) is the single identity — no second password, no invite flow beyond adding the westside-ops-user role.
  • Postgres connection is cached via @st.cache_resource. One connection pool per Streamlit pod, reused across requests and sessions. This avoids opening a new Postgres connection on every page click. psycopg2's connection object is thread-safe for the usage pattern (Streamlit serializes requests per session).
  • Queries are raw SQL in the Python file, not ORM or dataframe abstractions. Each page has its own SELECT statement with the joins it needs. Raw SQL is transparent (you can read the .py file and know exactly what Marcus sees), debuggable (any query error surfaces immediately with a line number), and fast (no ORM overhead for read-only joins). It also means the queries are pure documentation of the tool's data model.
  • All filtering is client-side in the grid, not round-trip to the database. The query returns the full dataset for the page (e.g., all 66 players); Marcus's filter clicks re-render the grid client-side from the already-loaded pandas DataFrame. This means filter latency is zero (no DB hop), and Marcus can rapidly iterate filters without any server work. For datasets that grow past ~10k rows, we'd reconsider — but westside's data sizes are far below that.
  • Session state lives in memory, not in a metadata database. When Marcus applies a filter, Streamlit's session state holds it until his session ends or the pod restarts. There is no persistent "saved view" mechanism in v1. If Marcus reloads the page, he re-applies the filter. This is a deliberate simplification — "filter fast every time" beats "store filter configurations forever" for operator workflows. If this proves wrong in practice, a follow-up ticket adds URL query params as lightweight bookmark support.
  • Copy-to-clipboard is the integration mechanism, not API calls. v1 does not POST to basketball-api's blast endpoint from a button. Instead, Marcus copies the email column and pastes into Gmail / GroupMe / whatever tool is appropriate for the task. This keeps the v1 surface area tiny (zero write paths, zero cross-service auth, zero API contracts) and matches the "AI never blocks" paradigm — Marcus is fully in control of what gets sent to whom. Integrated action buttons are a later story, added only if Marcus asks for them after using the copy-paste flow.
  • No background jobs, no async tasks, no cron. Streamlit is stateless request/response. Every operation Marcus takes is synchronous: click → query → render. This is both simpler and safer — there is no "running job" state to reason about, no way for a background task to silently corrupt data, no cron schedule to maintain.
  • arch-deployment-westside-ops — the infrastructure these flows run on
  • arch-domain-westside-ops — which tables each query touches and why
  • story-westside-ops-spreadsheet-access — the user story driving this flow
  • session_2026_04_03_email_overhaul — basketball-api's existing blast endpoint that a future "cohort action button" story would integrate with