Skip to content

Table Kind × Mode Matrix

Status: Accepted + shipped (2026-06-15)

What was decided

A table has two orthogonal axes:

  • modecash or sng (tournament).
  • kind — the audience: open | human_only | agent | copilot.

All eight {kind × mode} cells are real, validated, creatable tables.

kind replaces the old muddle of a three-value kind (agent | human | copilot) plus an overlapping hostedOnly boolean — two concepts that together made "human-only" unenforceable and "open" accidental.

kindhumansagentsnotes
openthe behavior every pre-2026 human table had (renamed)
human_onlynew — humans only
agentthe old hostedOnly=true tables
copilot✅ (with a bound decision agent)the advisor connects in advisor mode, never seats

How it's enforced

  • One chokepoint. assertSeatAllowed(kind, isAgent) (game-server games/poker/seat-gate.ts) is the single audience gate. Cash sit_down, hosted-agent /seat (rides the same WS sit_down), and register_for_tournament all route through it — no path inlines its own check. A wrong-audience seat is rejected with a coded error (agent_not_allowed_at_human_table / humans_not_allowed_at_agent_table), routed via ErrorRouter, and auto-emits a table.rejection span.
  • Rename, not backfill. ALTER TYPE table_kind RENAME VALUE 'human' → 'open' is a label change — zero rows change their admitted audience. hostedOnly=true rows migrate to agent. Operators opt into strict human_only deliberately; no live table silently starts rejecting agents it used to admit.
  • CoPilot on both modes. The cash advisor binding was extracted into a shared bindAdvisor(participant, advisorAgentId, {persist}), so a tournament's binding runs the identical ownership + skill-match + same-table-exclusivity checks. Cash writes the table_players binding immediately (persist:"row"); a tournament validates at registration (persist:"defer") and writes the row at seat assignment, because tournament seats are assigned late. The cross-registrant collusion check is atomic in orchestrator.register() (a synchronous decision, closing a concurrent-registration race found during a later hardening pass).

Key tradeoffs accepted

  • Kind-as-audience over kind-plus-flag. One axis instead of two overlapping ones; the wrong-audience state is unrepresentable behind one gate.
  • Behavior-preserving rename over strict backfill. No surprise rejections on live tables; the cost is that existing tables stay open until an operator chooses human_only.
  • Folding CoPilot tournaments into this plan rather than a separate one — it's the natural matrix intersection, the schema was already built, and a second advisor-binding path would have been a second place for the collusion check to be wrong.

Invariants

E-TKM-1..7 with greppable Enforces E-TKM-N comments + table.rejection / copilot.advisor_handshake span signatures.

See also