# Volleyboard — Plan v1

> The design + build plan for the entire game. Inputs: `idea.md`,
> `review-learnings.md`, `fleshout-2026-07-19.md` (fleshout wins conflicts),
> `/srv/projects/standards/` (coding.md, react.md, frontend.md), and the
> review app source at `/srv/apps/review`.
>
> Revision log (newest first):
> - v1.3 — critique cycle 3 (coherence + buildability) applied: §6.2 auth
>   ordering aligned with M0's corrected sequence; §7 layout aligned with
>   the single sticky strip; Erin's courts_of test case corrected (two
>   bands — she leads the back office); sound/animation implementation
>   specified; per-milestone testing story added (§4.2 test cases become a
>   literal pytest suite at M6); glyph cleanup per coding.md. Verdict: plan
>   is build-ready pending §0 morning sign-off.
> - v1.2 — critique cycle 2 (independent adversarial review, 13 findings, all
>   accepted in substance) applied: §4 rewritten as formal functions (the old
>   prose rule contradicted its own worked example); write-side projection
>   rules added; comment visibility scoping added; M0 auth ordering fixed
>   (was a deadlock); rep() stub moved to M1 + sensitive seeds to M6; §0
>   reframed as two stacked decisions with an honest overrule path;
>   per-court config specs incl. the status↔review coupling keeper; resolve
>   glide reconciled with session-snapshot rule; aging counts from latest
>   touch; reopen specified; strip assigned to M2; sort + editable
>   timestamps restored; UI language rule added. Critique text in §11.
> - v1.1 — critique cycle 1 applied: serve + put-away moments specified; one
>   sticky court strip (was per-court); count bubbles replace ball dots;
>   perspective closed over mentions + notification actors; filter renamed
>   "Mine". Critique text in §11.
> - v1.0 — initial draft (2026-07-19, overnight session)

---

## 0. MORNING SIGN-OFF — RESOLVED 2026-07-19

**DECISION MADE (Rian, pre-sleep):** Decision 1 (DB) = **local Postgres on
mosiah, no Supabase**. Decision 2 (frontend/runtime) = **the standards
stack** (Vite SPA + FastAPI), which this plan was already written on. A
Supabase-for-v1 detour was briefly considered and abandoned (free-tier
project limit + unwillingness to co-tenant the live review DB). The
build proceeds on the stack described throughout this document. The
original decision write-up is preserved below for the record.

---

### (original) one conflict surfaced, one recommendation

**The conflict (coding.md says surface, never silently resolve):**
`review-learnings.md` says "everything ports" from the review app's stack
(Next.js App Router + Supabase). But `/srv/projects/standards/react.md` — the
coding best practices Rian asked for by name — mandates **React SPA (Vite) +
FastAPI + Postgres** for rian-built product apps, with "no Node at runtime,
hold this line" stated explicitly. These contradict.

**Recommendation (this plan is written on it): follow the standards stack.**
Five reasons, strongest first:

1. **The soul feature demands it.** Perspective-relative rendering means the
   server must decide *per viewer* what a ball's history looks like. Supabase's
   model — client talks straight to the DB with RLS — cannot express "Erin sees
   Adi's sub-team's touches collapsed into Adi." (Proof from the source: the
   review app's RLS policies are just `authenticated full access using (true)`
   — RLS never did real authorization there.) A server-authoritative FastAPI
   app is not merely allowed here; the perspective model requires it.
2. **BW Auth Pattern B is Python-native.** The standard drop-ins (`bw_auth.py`,
   `bw_view_as.py`) are stdlib Python. On Next.js we'd hand-port the auth
   flow — exactly the "don't hand-roll auth" failure coding.md warns about.
3. **What actually ports is the design, not the files.** The schema shapes,
   the status-automation state machine, the notification design (kinds, dedup,
   context_label, resolved-propagation), and every hard-won UX rule port
   cleanly. The React components port as *patterns* (same shadcn-family
   primitives, same layouts). The Next-specific plumbing (server components,
   @supabase/ssr, middleware) was never the valuable part.
4. **Ops reality.** One Postgres sidecar (postgres:17 image already on the
   box) vs ~10 Supabase containers on a disk at 92%. One Python process to
   read and reason about — the stated principle of react.md.
5. **The realtime + image-paste experience is preserved** — see §6. Same feel:
   live board, paste-a-screenshot-and-it-just-works. Different plumbing (SSE +
   local storage), invisible to users.

**Framed honestly, these are TWO stacked decisions, not one** (the fleshout
asked only the first):

- **Decision 1 — database/plumbing** (the question fleshout actually asked):
  self-hosted Supabase stack vs plain Postgres + thin realtime + local
  storage. **Recommendation: plain Postgres**, on reasons 1, 4, 5 above.
  This one is strongly argued and low-regret either way rian leans.
- **Decision 2 — frontend/runtime** (implied, bigger): port the Next.js
  frontend or rewrite as Vite SPA + FastAPI per react.md. **Recommendation:
  the standards stack**, on reasons 1–3 — but this is the costlier call and
  the one that most deserves rian's morning eyes.

**Cost, honestly stated:** first app on the standards stack (react.md admits
none has shipped on it yet); rewriting components as patterns is slower than
copy-porting Next.js files — estimate ~20–30% more work across M1–M5. The
trade: the alternative fights the perspective feature forever.

**If Rian overrules Decision 2**, the honest blast radius is bigger than
"just §6": §5's ORM/migration tooling, §6.4's service-layer notifications
(which depend on no-client-writes), and the rep()-at-the-API-boundary
security argument all assume a server between client and DB. The workable
middle: **keep the FastAPI+Postgres server exactly as planned, and port the
review app's Next.js frontend file-for-file to talk to it as a pure API
client.** That preserves server-authoritative perspectives, kills most of
the 20–30% overhead, and costs only the "no Node at runtime" line of
react.md — a much smaller deviation to sign off than client-direct DB
access. Sections 1–5 and 7–9 survive under either resolution.

---

## 1. What we're building

**Volleyboard: the review app, gamified.** All of its proven machinery — the
board, filters, threads, mentions, notifications, presence, tags — wrapped in
a volleyball shell where work moves like a ball: served, passed, hit back over
the net, put away. And one genuinely new capability the review app never had:
**perspective-relative courts** — the same item is a different game depending
on who's looking.

**The experience bar (from fleshout — every feature judged against this):**
- Super usable. Obvious how it works once you start playing. Not over-gamified.
- Everything purposeful. The ball is an indicator of who has the action —
  no skill mechanic, no forced animations.
- Delightful like crossing off a to-do — but the *whole* app feels that way,
  including entering the work. You want to keep passing the ball.
- No scoring in v1.

## 2. Vocabulary (the whole game in nine words)

| Term | Meaning | Maps from review app |
|---|---|---|
| **Match** | A project (e.g. Brentwood) | project |
| **Court** | A section within a match — a collection type with its own columns and ball types | Page Reviews / Features / General Issues sections |
| **Ball** | One work item | page / feature / issue row |
| **Serve** | Creating a ball and sending it to someone | new issue (assigned) |
| **Pass** | Moving the ball to a teammate on your side | reassign within side |
| **Spike** | Sending it back over the net | "Ready for Review" auto-return |
| **Rally** | One ball's full journey until it's put away | item lifecycle |
| **Touch** | Any single movement of the ball | assignment change |
| **Put away / Ace** | Resolved / resolved in one touch | Resolved |

On naming the section concept: fleshout asked for a volleyball-flavored term
for "court configurations." **"Court" is the term** — a match contains
multiple courts (Pages court, Features court, Issues court, Warm-up court…).
It's spatial, obvious, and honest: each court really is a separate game
surface with the same rules. (Rejected: "rotations" — real volleyball meaning
doesn't map; "drills" — sounds like practice, this is the real game.)

Court configs are **declarative** (per review-learnings §1): a court row
defines its column layout and ball type; ALL courts share the machinery
(threads, mentions, notifications, presence, realtime, filters, authorship,
resolve). Adding a court type is data, not code. v1 ships four configs:

Each config specifies (so a builder never guesses): serve-sheet fields,
which columns render, ball types, whether/what drives the ball chip, and the
court's automation map.

1. **Pages court** — the review app's heart. Row = a page.
   Serve sheet: page name (required), staging URL, live URL, tags.
   Columns: name, staging/live link buttons, tags, status, review state,
   ball chip. **The ball chip is driven by the status↔review coupling**
   (the learnings §2 keeper, ported verbatim): "drafted" → review
   auto-becomes "ready for review" and the ball spikes to the client side;
   "revising" → "revision not ready", ball back with the dev side;
   "revision needed" → ball to the dev side (rally memory). One gesture,
   two fields, ball moves — users never micro-manage.
2. **Features court** — row = a markdown-described feature.
   Serve sheet: description (markdown). Columns: description, status,
   review state, ball chip. Same coupling map as Pages.
3. **Issues court** — free-floating issues. Serve sheet: title, body,
   category (Question/Bug/Revision/Changelog/Informational — last-used
   remembered), recipients (last-used remembered), tags.
   Columns: title, category, action-to (the ball chip IS the assignee
   control here), status, tags. Automations: Ready for Review auto-spike +
   rally memory + Informational→FYI (learnings §2, verbatim).
4. **Warm-up court** — the pre-game space idea.md wanted: access requests,
   credentials, questions, the requirements trickle. Structurally = Issues
   with ball types Request/Question/Requirement. Cheap because courts are
   declarative — proves the declarative claim on day one.

## 3. The rally loop (core mechanics)

### 3.1 State machine — keep the proven vocabulary, gamify the *motion*

The review app's status automations are already volleyball (learnings §2).
We keep the state machine **and its existing labels** — Erin already knows
"Ready for Review"; renaming proven workflow vocabulary to game slang would
trade usability for theme, which the experience bar forbids. The game layer
expresses itself in *who has the ball* and *how it moves*, not in renamed
statuses:

- Status → "Ready for Review" ⇒ ball **auto-spikes** to the author
  (assignees saved to rally memory). Animation: chip arcs over the net line.
- Status → "Revision Required" ⇒ ball **returns** to remembered assignees.
- Status → "Resolved" ⇒ ball is **put away**. Assignees clear. This is the
  crossing-off-a-to-do moment and must be the best interaction in the app,
  so it gets a specified beat: resolve sound + the row settles (brief
  highlight, ball chip drops and fades) and then — with show-resolved off —
  the row holds for ~2.5s before gliding out. The win is *seen* before it
  leaves; instant disappearance robs the dopamine the whole app exists for.
  **Snapshot-rule reconciliation:** the glide-out applies only to a resolve
  *you* perform (the celebration IS the acknowledgment — a deliberate,
  documented exception to session-snapshot filtering). Someone *else's*
  resolve dims your row to resolved styling and leaves it in place until
  your next reload — reactivity never yanks rows from under you
  (learnings §3).
- **Reopen** ("the fix didn't take"): anyone can reopen (same trust rule as
  status). Restores `prev_action_to` (rally memory, consistent with
  Revision Required), records a `reopen` touch, plays the pass sound, row
  re-enters the open view. Notifications un-resolve via the propagation
  rule.
- Category "Informational" ⇒ **announcement** — a ball that isn't meant to
  come back. No return expected, no wobble aging (see 3.3).
- One-touch resolve = **ace**. Slightly brighter resolve sound + a small
  "ACE" flourish on the row (one second, no modal, nothing blocking).

### 3.1b The serve moment (entering work must feel like play)

Fleshout is explicit: the delight includes *entering* the todo. The serve is
therefore a specified interaction, not a form:
- "+ Serve" in the sticky header (and `s` as a shortcut) opens the serve
  sheet with the title field focused. Court defaults to last-used; recipient
  defaults to last-used. Keyboard-only path: type title → Tab → pick
  recipient → Cmd/Ctrl+Enter.
- On serve: the sheet closes instantly (optimistic), the whoosh plays, and
  the new row lands in its court with the ball chip arcing onto the
  recipient's avatar. You *see* your serve land. Total cost of serving a
  ball: one shortcut, one line of text, one keystroke. If serving feels
  heavier than sending a Slack message, this section has failed.

### 3.2 Who has the ball

`action_to` (multi-assign array — "either of the juniors can take it") is
rendered as the **ball chip**: the ball icon sitting next to the avatar(s) of
whoever must act. Every row answers "whose ball is it?" at a glance. Passing
is one interaction: click the ball chip → pick a player (same inline-edit
pattern as every pill in the review app) → the chip *flies* to the new
avatar (200–350ms arc, one soft bounce sound). That's the entire mechanic.
No skill, no timing, no arcade — exactly the fleshout bar.

### 3.3 Tempo pressure (vibe, not rule)

A ball whose **latest touch** is older than the court's aging window
(default 3 business days) gets a subtle aging cue: the ball chip dims and
gains a slow wobble. Not red alerts,
not counters — the visual equivalent of a ball sitting on the floor waiting.
Aging clears on any touch. Announcements ("FYI Only") and Resolved never age.
Touch limits from idea.md stay OUT of v1 (idea.md itself leaned "vibe first";
enforcement adds rules to learn, against the bar).

### 3.4 Sound design (small, purposeful, opt-out-able)

Five sounds total, all under 400ms, soft by default:
serve (airy whoosh), pass (soft bounce), spike over net (satisfying thock),
resolve (bright ding — the star of the app), ace (ding + harmonic).
Rules that keep it delightful instead of annoying:
- Sounds play for **actions you take** and **balls arriving to you**. Other
  people's rallies elsewhere on the board are silent for you (their motion
  still renders). An open-plan office of dings is the failure mode.
- Per-user master volume + mute in one click (header). Persisted.
- No sound on page load, ever.
- **Implementation (so 3am doesn't improvise):** five tiny audio files
  synthesized offline and bundled locally in the repo (no CDN, no licensing
  ambiguity), played via a single `sounds.ts` module (the one place volume,
  mute, and the "whose action was it" gate live). Chip flight is a
  FLIP-style CSS transform between row positions — no animation library;
  the strip arcs are a single SVG path animation. All motion respects
  `prefers-reduced-motion`.

### 3.5 The rally timeline

Opening a ball (side sheet, never a route — learnings §6) shows the **rally**:
a vertical timeline interleaving touches (serve → passes → spikes, each with
avatar, timestamp, and which side of the net) and the conversation (comments,
notes, screenshots). The thread IS the rally. This reframes the review app's
threads as the game's play-by-play — same data, more story.

## 4. Perspectives (the soul) — teams, nets, and who sees what

### 4.1 The team model

A match has two **sides** (e.g. Bowden Works / Brentwood). Each side is a
**tree of team nodes**. A player belongs to exactly one node per match. Each
node has a **boundary player** (its lead — the person who represents the
node to everyone above it).

Worked example (the fleshout roster — also the seed data):

```
SIDE: Bowden Works             SIDE: Brentwood
└─ Front line: Rian, Adi       └─ Front line: Erin, Tracy
   └─ Tingang (lead: Adi):        └─ Back office (lead: Erin): Rob
      Zeina, Rahman, Stevan
```

### 4.2 The visibility model (three functions, not prose)

An earlier draft stated this as a one-sentence rule; the fresh-eyes critique
proved the sentence contradicted the worked example. So: three small pure
functions are the specification. The examples below are *test cases* for
them, not the spec itself.

```
courts_of(V):
    # every node V belongs to gives V a court; being a boundary player
    # gives V the sub-court too
    courts = [ court(N) for N in nodes_where_member(V) ]
           + [ court(N) for N in nodes_where_boundary(V) ]

court(N):
    if N is a front-line (root) node:
        near = members(N)
        far  = members(front_line(opposing_side))      # individuals
        far_label = opposing side's name
    else:   # a sub-team: its "opponent" is its own parent context
        near = members(N)                               # excludes boundary
        far  = [ boundary_player(N) ]                   # the lead stands
        far_label = root side's name                    #   across their net

visible_players(V) = union of near ∪ far over courts_of(V)

rep(P, V):                       # who does viewer V see for player P?
    if P in visible_players(V):  return P
    N = node(P)
    while N is not a root:       # collapse into the nearest visible lead
        if boundary_player(N) in visible_players(V):
            return boundary_player(N)
        N = parent(N)
    return side_label            # beyond V's deepest visible net:
                                 #   the far-side label of V's own court
```

Test cases — every fleshout assertion, now derivable:
- courts_of(Rian) = [big court]: near Rian+Adi, far Erin+Tracy. [pass]
- rep(Zeina→anyone upstream of Adi, Rian) = Adi; rep(Rob, Rian) = Erin.
  Rian never sees Tingang or Rob. [pass]
- courts_of(Adi) = [big court, Tingang court] — two bands, himself in the
  middle (near-side player on the big court, far-side lead on Tingang's). [pass]
- courts_of(Zeina) = [Tingang court]: near Zeina+Rahman+Stevan, far = Adi
  under the label "Bowden Works". visible_players(Zeina) ∌ Rian, Erin,
  Tracy — a serve that originated with Erin renders in Zeina's timeline as
  the far-side label. (Deliberate: from inside a sub-court, *everything*
  beyond your net is just "over the net"; naming Brentwood to Zeina would
  leak structure her game doesn't contain.) [pass]
- courts_of(Erin) = [big court, back-office court] — Erin is boundary of
  Back office, so like Adi she gets two bands (of course she sees her own
  back office). On the big court she sees Rian + Adi as individuals (Adi
  behind Rian in strip layout order), nothing below Adi. [pass]
- courts_of(Rob) = [back-office court]: near Rob, far Erin, label
  "Brentwood". [pass]

### 4.3 Reading through the projection

The server resolves **every** payload through rep() before it leaves the
API: touches, ball chips, count bubbles, presence, notification actors,
mention autocompletes, rally timelines.

- A Tingang rally (Adi → Rahman → Adi) renders to Rian as "ball is with Adi"
  — those touches simply don't exist in Rian's payload. To Erin the same
  stretch renders as "ball is with the Bowden side."
- **The API never ships invisible touches to the client.** Perspective is
  server-authoritative — not a UI filter someone can pop open devtools to
  defeat. This is why the stack recommendation in §0 is what it is.
- **Perspective closes over every surface, not just touches:** the @mention
  autocomplete only offers players visible to the author (Erin cannot
  mention Zeina — she doesn't know Zeina exists); notification actors render
  through rep() (a notification reaching Zeina about Erin's action says
  "Brentwood", not "Erin"); presence, count bubbles, and the rally timeline
  all pass through the same one function. One rep(), applied at the API
  boundary, everywhere — never per-feature visibility logic.
- History is preserved raw in the DB (`touches` is append-only); rep() is a
  read-time projection. Change the team tree later and past rallies re-render
  correctly under the new structure.
- **Comments are scoped, not just anonymized.** rep() on authorship alone
  would show Erin the *content* of Tingang's internal chatter with the name
  stripped — worse than a leak, an unaccountable one. So every comment
  carries `acting_node_id` (the node context it was written in, same as
  touches), and the rally timeline shows a viewer only comments whose node
  is visible to them. An invisible stretch renders as one collapsed line —
  "3 touches on the Bowden side" — no bodies, no authors. Sub-court
  conversation stays in the sub-court; that is idea.md's founding promise
  ("the ball can dip into a sub-team and come back out without the other
  side ever seeing those touches").

### 4.3b Acting through the projection (the write side)

Rendering is only half; every write that crosses a visibility boundary has a
defined meaning:

- **Passing to a collapsed avatar targets the representative, honestly.**
  Erin passes to "Adi" while the true holder is Zeina: the write assigns
  Adi (`action_to = [adi]`), records a real touch, and Zeina's side sees
  the ball recalled upstream. No ghost-assignment to players the actor
  can't see.
- **Cross-net automations only fire toward visible targets.** A status
  change whose automation would send the ball to a player invisible to the
  actor is simply not offered in that actor's status menu (Rahman's menu on
  a big-net ball omits "Ready for Review" — he passes to Adi, who has the
  standing to spike). The menu is filtered by the same rep() machinery —
  no special-case logic.
- **Serving/spiking "over the net" from inside a sub-court** (fleshout:
  Tingang may hit "directly over the net") targets the far side of *your*
  court — which for a sub-court is its boundary player. Zeina's
  over-the-net serve assigns Adi. If Adi has an autopass rule, it carries
  the ball onward in the same breath (§4.4) — that is exactly the fleshout
  scenario, composed from two simple rules instead of one complex one.

### 4.4 Autopass (automation as a teammate)

A player can define a rule: "when a ball is passed to me **from my sub-team**
(or: from anyone / from player X), auto-pass it to player Y." Stored
per-player per-match, max one hop (an autopass never triggers another
autopass — loop-proof by construction).

Honesty requirement: an auto-touch renders with a small bolt-glyph and
"(auto)" in the rally timeline — Adi's teammates should never wonder how the
ball teleported. Adi sets it in his settings sheet: two dropdowns and a
toggle, nothing more. This is idea.md's "automation as a ball-machine,"
scoped to its smallest useful form.

### 4.5 The court strip (how perspective is *felt*, not explained)

**One court strip, sticky, directly under the header** — not one per court
(sides are per-match, so per-court strips would repeat the same people four
times and dilute the game into wallpaper). The strip is the game, always on
screen: your side's avatars left, net line center, visible opponents right.
- **Presence glow** on whoever's online now (this IS the presence widget —
  presence lives on the court, purposefully).
- Each avatar carries a **count bubble**: how many open balls that player is
  holding (in your perspective). One glance answers "who's loaded up?" —
  a genuinely useful workload read, not decoration. Clicking a bubble
  filters the board to that player's balls. (Individual ball dots were
  rejected: dozens of open balls would be noise.)
- Ball-motion animations (serve arcs, spikes over the net line) play across
  the strip as they happen live — the strip is where you *watch* the game.
- Adi's strip shows two stacked bands (big court + Tingang). Zeina's shows
  one. Nobody is told "you have perspective-relative rendering" — they just
  see *their* game, which is the whole point.
Court sections below keep only a slim header: court name, net-line rule,
StatChips. No repeated rosters.

## 5. Data model

Postgres 17, SQLAlchemy 2.0 typed ORM, Alembic migrations (react.md). The
review schema's shapes carried over near-verbatim where they're mature
(coding.md: don't "improve" a schema during a port) but with the generic
parent the learnings demanded, and the game's new tables.

```
players        id, username (BW username), email, display_name, avatar_color,
               sound_settings jsonb, created_at
               -- no password/session/user table: identity is BW Auth (Pattern B)

matches        id, slug, name, created_at                     -- "projects"
sides          id, match_id, name ("Bowden Works"), position (1|2)
team_nodes     id, side_id, parent_node_id nullable, name,
               boundary_player_id → players
team_members   node_id, player_id, unique(match, player)

courts         id, match_id, config_key, name, position,
               config jsonb   -- columns, ball_types, aging_days, defaults
balls          id, court_id, title, body md, status, review_state,
               category, tags text[], url_staging, url_live,
               action_to uuid[] (players), prev_action_to uuid[],
               author_id, resolved_at, position, created_at
               -- one table, all courts; config decides which cols render.
               -- Generic by construction: no per-court-type nullable FKs.

touches        id, ball_id, seq, kind (serve|pass|spike|resolve|reopen|auto),
               from_player, to_players uuid[], acting_node_id,
               via_rule_id nullable, created_at    -- append-only; rally = ORDER BY seq

comments       id, parent_type ('ball'|'touch'), parent_id, author_id,
               acting_node_id,   -- visibility scope (§4.3): written-in context
               body md, created_at (author-editable — learnings §9 backdating),
               edited_at
               -- polymorphic parent per learnings §1 (parent_type+parent_id,
               --   NOT nullable-FK-per-type)
attachments    id, comment_id, file_path, mime, bytes, created_at
               -- pasted screenshots; file on local volume, served authed

notifications  id, recipient_id, actor_id, kind (mention|thread_reply|action_to),
               category, context_label, body (160-char preview), link,
               read bool, resolved bool, source_type, source_id, created_at
               -- design copied wholesale from review migrations 0014–0017;
               -- mechanism moves from DB triggers to the service layer (§6.4)

autopass_rules id, match_id, owner_player_id, trigger (from_subteam|from_any|
               from_player), trigger_player nullable, target_player, active

audit_log      id, actor_id, action, entity_type, entity_id, detail jsonb,
               created_at        -- react.md: every state-changing action
```

Presence is in-memory in the single Python process (no table).
Status vocabularies live in ONE constants module served to the client via the
API (frontend.md: enums from one authority).

## 6. Architecture

### 6.1 Stack (per standards — see §0 for the decision)

- **One Python process**: FastAPI + Uvicorn, serving the API and the built
  Vite `dist/` via StaticFiles, behind Caddy on `172.17.0.1:3131`.
- **React SPA**: Vite, TypeScript, Tailwind v4, shadcn-family vendored
  primitives (Base UI like review — its `render`-prop idiom is known-good),
  lucide icons, sonner toasts, TanStack Query as the query layer.
- **TS client generated from OpenAPI** — never hand-written interface mirrors.
- **Postgres 17 compose sidecar** on the project-internal Docker network only —
  no host port, unreachable from LAN/VPN. Alembic `upgrade head` runs on app
  container start.
- Sync `def` for CRUD routes; `async def` only for SSE. (react.md's explicit
  override of the async-everywhere instinct.)

### 6.2 Auth — BW Auth Pattern B + View As

- Follow `/srv/system/id-auth/app-auth/README.md` + PATTERN-B.md; copy
  `bw_auth.py` in as the drop-in. App session is the app's own cookie after
  the OAuth2+PKCE dance with auth.bowden.works.
- **Default-deny AuthMiddleware**: everything requires a session except the
  explicit allowlist (`/health`, `/auth/callback`, static assets). No broad
  `/api/*` exemptions, ever (documented server failure).
- `role_of(username)`: derived from team membership (boundary players →
  "lead", others → "player"); reported to the hub per Pattern B.
- **View As**: `bw_view_as.py` drop-in, read-only default, server-side authz
  hooks, impersonation reported to the hub. This is also the *build tool*:
  the builder signs in as rian and uses View As to verify every perspective
  in §4.2 renders correctly for Erin, Adi, Zeina, Rob.
- **Ordering:** registration follows the corrected five-step sequence in
  §8/M0 (wire flow → LAN-only → register → verify over LAN → stay LAN-only
  until M6) — registering auto-un-gates the host, so LAN-only is the shield
  while login is unverified. Register with
  `--secret-out /srv/apps/volleyboard/.bw-auth.env` (0600) — the secret
  never appears anywhere else.

### 6.3 Realtime — SSE, server-authoritative

- One endpoint: `GET /api/events` (SSE via sse-starlette). Because every
  write flows through the service layer of a single process, publishing is an
  in-process event bus — no LISTEN/NOTIFY, no message broker, nothing to
  operate. (If the app ever scales to multiple processes, LISTEN/NOTIFY slots
  in behind the same bus interface.)
- Events are **perspective-filtered before send** — the same rep() function
  §4.3 uses. A Tingang touch never even reaches Rian's event stream.
- Client: TanStack Query invalidation on event receipt + targeted optimistic
  updates with snapshot rollback (learnings §7). Presence piggybacks on the
  SSE connection (connect = on court, disconnect+grace = off).
- **M0 must verify SSE flushes end-to-end through Caddy** (react.md calls
  this the classic streaming failure; verify early, not at the end).

### 6.4 Notifications — the review design, relocated

Everything in review-learnings §4 is kept: three kinds, participation =
subscription, mention-beats-thread_reply dedup, category pill +
bold context_label, 160-char stripped preview, deep link with `_t`
cache-buster, resolved-state propagation, the bell UI details (grey badge,
bounce-on-load, show-resolved toggle).

One relocation, stated honestly: review created notifications in
security-definer DB triggers because untrusted clients wrote directly to the
DB. Here **no client writes directly to the DB** — so notification creation
moves to the service layer (same transaction as the triggering write), where
it's pytest-testable and can publish SSE in the same breath. The *design*
ports wholesale; the *mechanism* moves to where this architecture wants it.

### 6.5 Storage — pasted screenshots

`use-image-paste` UX ported exactly: ⌘V → instant data-URL preview → upload.
Backend: `POST /api/attachments` (size/mime-capped) → UUID-named file on the
project volume (`./data/uploads`, never web-root-served) → rendered via an
authed `GET /api/attachments/{id}` route behind the default-deny middleware.
Same experience as Supabase signed URLs; simpler trust model (the session IS
the signature).

### 6.6 Deploy + versioning

`srv-gw deploy --project volleyboard --build` (multi-stage Dockerfile: Node
build stage → Python runtime stage). App version + newest-first changelog in
one source file, version visible in the footer (coding.md: the visible
version is deploy confirmation). Compose: app + postgres sidecar, internal
network, `172.17.0.1:3131` binding untouched.

## 7. UI — the board is still a board (that's the point)

One long page per match (learnings §7): sticky compact header (match name ·
court jump-links · +Serve button · mute toggle · bell · user), the single
sticky court strip (§4.5) directly beneath it, then courts stacked below —
each just a slim section header (name · net-line rule · StatChips) + table.

Kept verbatim from the review app (all proven, all port as patterns):
- Inline-editable everything: status pills, review pills, ball chip,
  category, tags — click, pick, done. No edit modes.
- StatChips as scoreboard tiles (overview + filter in one control).
- Column-header filters, expandable search, tag multi-select; **"Mine"**
  (Action to: Me) as the default filter — deliberately not "My balls";
  session-snapshot filtering (`sessionTouchedRef` — rows you touched stay
  put until reload).
- Side sheets for detail (deep links open sheets, never scroll-to-row).
- Composer: markdown + GFM + interactive task checkboxes, @mention
  autocomplete (upward, mousedown-select, 120ms blur grace), paste
  screenshots, last-used category/assignee remembered.
- Column-header **sort** (fleshout names sort/filter/search as keeps):
  manual `position` is the default order; clicking a sortable header
  overrides per-session; drag-reorder only available under default order.
- One color language per enum, defined once (status-styles equivalent);
  real `<a>` links for staging/live URLs; collapsed long cells;
  deterministic UTC timestamps; author-editable comment timestamps
  (backdating — learnings §9).

**UI language rule (one paragraph, so no surface ever guesses):** workflow
STATE speaks the review app's vocabulary everywhere it appears — status and
review pills, filters, StatChips, notifications ("Ready for Review",
"Resolved"). Volleyball words appear only on ACTIONS and MOMENTS: the
"+ Serve" button (with a "new request" subtitle until first use), pass/spike
affordances on the ball chip, sounds, motion, the rally timeline's verbs,
and the how-to-play sheet. State = familiar nouns; play = game verbs. A
status pill never says "Put away"; a toast celebrating your resolve may.
This is the seam that keeps the metaphor delightful instead of confusing for
a non-technical client.

The game layer adds exactly three visual elements — court strip, ball chip
with motion, aging wobble — and five sounds. Everything else *is* the review
app's usability, untouched. That ratio (proven machinery : new game skin) is
deliberate and is the plan's main defense against over-gamification.

Mobile: works at phone width (frontend.md floor). Court strips compress to
avatar clusters; tables become card lists (the review app's responsive
pattern).

## 8. Build sequence — every milestone independently shippable

Alembic revisions numbered per milestone. Each milestone ends deployed,
version-bumped, and verified in the browser (View As once M1 lands).

**Testing (react.md: behavior tests, not render tests):** every milestone
lands pytest behavior tests for its services in the same commit — above all
the automations (status → touch effects) and, at M6, **the §4.2 test cases
verbatim as a parametrized pytest suite over courts_of/visible_players/rep**
(they are written as derivable assertions precisely so they can be executed,
not just believed). The generated TS client + `tsc` is the cross-boundary
check; Playwright is reserved for the two juice-critical flows (serve
moment, resolve moment) in M7.

- **M0 — Walking skeleton + the risky bits first.**
  Scaffold (FastAPI app/{routers,services,models} + Vite SPA), Dockerfile,
  compose with postgres sidecar, Alembic 0001 (players, matches, sides,
  team_nodes, team_members, courts, balls, touches — the perspective-ready
  core), default-deny middleware, SSE hello-world verified through Caddy,
  version footer.
  **Auth sequence, corrected (the naive order deadlocks — you can't verify
  login before registering, and registering auto-un-gates the host):**
  (1) wire the full Pattern B flow in code (unverifiable without creds);
  (2) `srv-gw id-site-set --host volleyboard.bowden.works --lan-only true`
  (the CLAUDE.md-sanctioned way to hide a Pattern B app pre-launch);
  (3) `srv-gw app-client-register --secret-out .bw-auth.env` (which
  un-gates — harmless, host is LAN-only);
  (4) verify login end-to-end over LAN/Tailscale;
  (5) leave LAN-only ON until M6's View As walkthrough passes — see the
  audience note below.
  *Ships: you can sign in with BW and see a live heartbeat.* De-risks:
  auth, SSE-through-Caddy, first-app-on-stack.
- **M1 — The board.** Courts + balls tables with inline edit + sort,
  StatChips, filters/search/tags ("Mine" — Action to: Me — as default
  filter), side sheet, seed data loaded (§9 — front-line world only).
  **rep() is born here as a stub** (flat visibility: both front lines see
  each other; no sub-teams exist yet) and every read/event flows through it
  from day one, so M6 changes one function, not every feature. Roles
  reported to hub; View As wired. *Ships: the review app's core usability,
  on Brentwood data, in Volleyboard.*
  **Audience note (privacy before M6):** sub-team and back-office seed data
  (Tingang rallies, Rob's court) do NOT exist before M6 — the pre-M6 board
  contains nothing perspective-sensitive, and the host stays LAN-only
  regardless. Both guards must hold before granting the wider roster access
  to a build.
- **M2 — The rally.** Touches (serve/pass/spike/resolve/reopen), status
  automations ported (auto-spike on Ready for Review, rally memory, clear on
  Resolved, FYI announcements, the Pages status↔review coupling), **the
  court strip** (single sticky band, seeded sides through the rep() stub,
  count bubbles), ball chip + flight animation + the five sounds + ace
  flourish + aging wobble. *Ships: the game FEELS like the fleshout
  demands. This is the milestone to show Rian.*
- **M3 — Live.** SSE event bus wired to all writes (events pass through the
  same rep() boundary as reads — stub now, smart at M6), optimistic updates
  + rollback, presence glow on the court strip. *Ships: two people rally in
  real time.*
- **M4 — The conversation.** Polymorphic comments, composer (markdown,
  checkboxes, mentions, paste-screenshots + attachments route), rally
  timeline in the sheet. *Ships: threads = play-by-play.*
- **M5 — The bell.** Notification service (all §6.4 semantics) + bell UI
  ported. *Ships: nobody misses their ball.*
- **M6 — The soul.** Team trees + the real rep()/courts_of/visible_players
  (§4.2) replacing the stub, write-side projection rules (§4.3b), comment
  scoping (§4.3), multi-band strip on Adi's view, autopass (rule +
  bolt-glyph honesty + settings UI), sub-team + back-office seed data added
  (§9), View As walkthrough asserting every §4.2 test case. On pass: flip
  LAN-only off. *Ships: Zeina's game ≠ Rian's game ≠ Erin's game.*
- **M7 — Polish + warm-up court.** Warm-up court config (proves declarative
  courts), sound/motion tuning pass, mobile pass, empty states, a 60-second
  "how to play" first-run sheet (three sentences, not a tour).

Sequencing note: M2 before M3/M6 because the *feel* is the riskiest product
bet — prove it on one shared court before spending nights on plumbing.
Perspective data (touches, nodes) exists from 0001, so M6 is a projection
layer, not a rewrite (fleshout's "one shared court, model it
perspective-ready" instinct, honored).

## 9. Seed data — Brentwood as test content

Source: the review app's own Supabase (creds in `/srv/apps/review/.env` /
`.env.migrate`, readable by rian; session-pooler URI — direct URI is
IPv6-only and unreachable from mosiah, per learnings).

- One-off export script (`tools/seed-from-review.py`): SELECT pages (with
  tags, statuses, URLs), features, general issues, and a representative
  slice of notes/comments (with author emails) → JSON snapshot committed to
  `seed/brentwood.json` (screenshots NOT exported — attachments seed empty;
  no secrets in the snapshot).
- Transform: pages → Pages-court balls; features → Features court; issues →
  Issues court; statuses/review states map 1:1 (same vocabulary, kept
  deliberately); authors map by email to players.
- Roster seeded per §4.1: rian, adi, erin, tracy, rob (real BW accounts,
  already granted on the domain) + Zeina/Rahman/Stevan as seed players on
  Tingang (no BW accounts yet — they exist so sub-court rallies render; can
  be granted real accounts later).
- Rally bootstrapping: each ball gets a synthesized serve touch from its
  real author, and open balls get action_to mapped from the review app's
  assignments — the board is mid-game on arrival, which is exactly the demo
  Rian wants to wake up to.
- **Seed touches are stamped recent** (spread over the last ~48h), because
  aging counts from the latest touch (§3.3) — otherwise weeks-old Brentwood
  timestamps would make Rian's first impression a wall of wobbling,
  dimmed balls: the tempo nudge presented as a nag, deterministically. Two
  or three balls are deliberately back-stamped past the aging window so the
  wobble is *demonstrated*, curated, not epidemic.
- **Perspective-sensitive seeds ship with M6, not M1** (see §8): a handful
  of hand-authored Tingang sub-rallies and one Rob back-office rally, so
  every §4.2 test case is demonstrable with View As the moment the real
  rep() lands — and never renders on a board that can't yet hide them.

## 10. Risks

1. **First app on the standards stack** — no reference implementation.
   Mitigation: M0 front-loads every unproven integration (auth, SSE/Caddy,
   build pipeline); react.md explicitly expects this app to feed back
   corrections to the standard.
2. **Sound/motion taste is subjective.** Mitigation: all juice behind small
   pure functions + one settings surface; M7 tuning pass with Rian awake.
3. **Perspective edge cases** (player moves nodes mid-rally, boundary player
   leaves). Mitigation: rep() is a read-time projection over append-only
   touches — restructuring re-renders history safely; node changes are
   audit-logged.
4. **Disk at 92%.** This app is small (DB + screenshots), but flag to Rian:
   brentwood project alone is 81GB (security audit) — server-level cleanup
   is due regardless of Volleyboard.
5. **Seed export drift** — review's schema may shift before the seed script
   runs. Mitigation: snapshot to JSON once, commit it; the seed never reads
   review live after that.

---

## 11. Critique log

### Cycle 1 (self, experience-bar focus) — applied in v1.1

1. **Under-gamification was the real risk, not over-gamification.** v1.0
   specified restraint everywhere but gave no interaction spec to the two
   moments that must carry the entire game feel: the serve and the put-away.
   Fixed: §3.1b (serve moment — keyboard-fast, whoosh, visible landing) and
   the resolved-row 2.5s hold in §3.1 (the win must be seen before it
   leaves).
2. **Per-court strips would have been wallpaper.** Same roster repeated four
   times = the game fades into decoration, plus wasted vertical space.
   Fixed: one sticky strip (§4.5); court sections keep slim headers.
3. **Ball dots didn't scale**; replaced with per-player count bubbles that
   double as workload glance + click-to-filter. Purposeful, not decorative.
4. **Perspective had leaks** at mentions and notification actors — Erin
   could have mentioned Zeina; Zeina could have seen Erin's name. Fixed:
   rep() closes over every surface at the API boundary (§4.3).
5. **"My balls"** renamed "Mine". Client-facing app.

### Cycle 2 (independent fresh-eyes adversarial review) — applied in v1.2

Thirteen findings, all accepted in substance. The deepest cluster: §4 was
specified by example rather than by rule, and the examples disagreed with
the rule. The material ones:

1. **Visibility rule contradicted its own worked example** (the prose rule
   gave Zeina sight of Rian/Erin/Tracy; the example said side-label only).
   Fixed: §4.2 rewritten as three pure functions with the fleshout
   assertions as derivable test cases.
2. **Writes through the projection were unspecified** — Erin passing to a
   collapsed "Adi", Rahman triggering a cross-net auto-spike toward people
   he can't see, Zeina serving at a side label. Fixed: §4.3b.
3. **rep() anonymized who, not what** — Erin could read Tingang's internal
   comments with names stripped. Fixed: comments carry `acting_node_id`;
   invisible stretches collapse to a countline with no bodies (§4.3).
4. **M1–M5 shipped a privacy-violating board** to already-granted users,
   and M3 referenced perspective filtering three milestones before rep()
   existed. Fixed: rep() stub born M1 with all reads/events flowing through
   it; sensitive seeds deferred to M6; host LAN-only until the M6 View As
   walkthrough passes.
5. **M0's auth ordering was a deadlock** (verify login before registering —
   impossible; and registration un-gates mid-build). Fixed: wire → LAN-only
   → register → verify over LAN → stay LAN-only until M6.
6. **§0 conflated two decisions and understated the overrule cost.** Fixed:
   split into DB decision + frontend decision; honest blast radius; hybrid
   (FastAPI server + ported Next.js frontend) named as the middle path.
7. **Pages court had no game semantics** and the status↔review coupling
   keeper was missing entirely. Fixed: per-court config specs in §2.
8. **Resolve glide-out contradicted the session-snapshot keeper.** Fixed:
   own-resolve celebrates and glides; others' resolves dim and stay.
9. **Seeded weeks-old data + aging = a wall of wobble on wake-up.** Fixed:
   aging counts from latest touch; seeds stamped recent; wobble curated.
10. Reopen behavior, court-strip milestone assignment, column sort,
    author-editable timestamps, and the UI language rule (state = workflow
    nouns, play = game verbs) — all specified.

### Cycle 3 (self, coherence + buildability after the v1.2 surgery) — applied in v1.3

1. **§6.2 still described the deadlocked auth ordering** that cycle 2 fixed
   in M0 — a contradiction introduced by the fix itself. Aligned to the
   five-step sequence.
2. **§7's layout line still described per-court strips** (pre-v1.1 design).
   Aligned to the single sticky strip.
3. **A §4.2 test case contradicted the §4.2 functions:** Erin is boundary
   of Back office, so courts_of(Erin) is two bands, like Adi — the function
   was right, the test case was wrong. (Caught precisely because cycle 2
   turned prose into derivable functions.) Fixed.
4. **Sound/animation implementation was unspecified** (asset origin,
   technique) — added: bundled synthesized files, one sounds.ts gate, FLIP
   transforms, prefers-reduced-motion. No CDN, no animation library.
5. **No testing story** despite react.md's explicit demands. Added: behavior
   tests per milestone; §4.2 test cases become a literal pytest suite at
   M6; Playwright reserved for the serve + resolve moments.
6. Checkmark glyphs replaced with [pass] (coding.md: no emoji in committed
   files — owner preference, explicitly stated as overriding model default).

### Verdict after three cycles

The remaining open items are genuinely Rian's, not the plan's: the §0
Decision 2 sign-off (stack), and taste calls (sound character, aging window)
that M2/M7 exist to tune with him awake. The perspective model is now
specified as executable functions with test cases; every milestone has an
unambiguous scope, an audience guard, and a shippable end-state; every
review-app keeper named in the learnings doc is either explicitly ported or
explicitly relocated with the reason stated. I'd stake the build night on
this plan.
