Wardroom
Docs navigation

Docs

Design

wardroom — the team hub — the design document, verbatim.

Design doc, 2026-09-14. Status: agreed in shape, not yet built. The reasoning and the alternatives considered are in docs/ANALYSIS-2026-09-14.md; this file is the part to build from.

1. Why this exists

The team runs half a dozen products, each with its own console, its own GitHub org, its own support address and its own pipeline. Nothing shows the crew one timeline. Wardroom is that timeline: a space per org, and inside it channels that are feeds of typed threads from the tools that already exist — a PR from GitHub, a ticket from purser, a lead from purser, an alert from tripline, a deploy from runsheet — plus ordinary conversation between the people who run the thing.

It is the Latchkey move again: internal-first shared infrastructure on the house stack (loom + Latchkey + @latchkey/shell), promoted to a sellable product only if it earns it. The differentiator, if it ever sells, is “team chat for Latchkey-stack orgs where your own tools are first-class threads” — not “cheaper Slack”.

2. Decisions locked (2026-09-14)

  1. Own repo, own loom service, own DB, own Cloud Run service — the tripline template. Wardroom, purser and foghorn are peers.
  2. A space is a Latchkey tenant. One Latchkey org wardroom; a tenant per org-with-a-team (grapevine, inflow, optrader, latchkey, …), the same slugs every other house service uses for the same products. Namespace wardroom/<slug>. Staff (wardroom org owners, *) see every space. GitHub org ↔ space is an explicit binding (GrapevineNetworkgrapevine), never inferred.
  3. Wardroom never owns another tool’s truth. Mirrored objects carry a deep link; the source tool is the system of record. This is what keeps wardroom small and keeps purser and foghorn independent.
  4. One aggregate per message, not messages-as-events on the channel. A #general lives for years; grapevine’s per-conversation Chat stream with @snapshot is right for a job match and wrong here.
  5. Typing and presence are not persisted (grapevine’s call). Not v1.
  6. v1 acts on nothing. Cards deep-link to the PR, the ticket, the lead. In-thread actions (reply to customer, move a lead) are v1.5, via Latchkey token exchange, and only for the two or three actions a person does fifty times a day.
  7. Customer-facing chat (a website widget) is not this. It would be a customer channel landing in purser as a ticket, and is deferred anyway.

3. Shape

  GitHub App webhook ─┐                       ┌─→ SSE timeline ─→ console (app.wardroom.id)
  tripline alerts ────┼→ hooks.wardroom.id ──→│   Channel / Message / ObjectThread
  purser webhooks ────┤   (HMAC, one delivery │   ReadMarker / MessageRow
  runsheet deploys ───┤    = one unit of work)│
  generic per-channel ┘                       └─→ notify: mention → web push / email digest

Domain (loom, first cut)

  • record Space — slug, name, settings, GitHub installation ids, bound purser workspace, bound foghorn workspace. Records converge; adding a space is one upsert after the Latchkey tenant exists.
  • aggregate Channel (ns = space) — name, kind: general | repo | support | leads | alerts | deploys | feed, binding (repo full name / purser workspace / webhook id + secret), visibility (public within the space in v1; private channels with their own membership later), archived. Commands: Create, Rename, Bind, Archive.
  • aggregate Message — channel, author (sub, or a connector identity), parent_id (threads), body, attachments (loom uploads), reactions, edited/deleted. Commands: Post, Edit, Delete, React, Pin. Ordering is uuid7 / created_at; chat needs monotone-ish, not serialized.
  • record ObjectThread — keyed uuid5(kind, external id) (pr:<repo>#<n>, ticket:<id>, lead:<id>, issue:<tripline id>) → root message id. Connector redeliveries converge on one thread; state changes append system messages under it.
  • record ReadMarker (person, channel) → last read message. Unread is a count over MessageRow past the marker.
  • record MemberPreference (person, space) — digest pace, mention rules; grapevine’s shape.
  • MessageRow @table — channel, ts, author, parent, body, kind — the timeline read, the thread read, and (side index, tsvector) search.
  • Notifications: a process on mention / thread reply / keyword decides per preference; a journaled effect sends web push or an email digest. Mobile push when the Expo app exists.

Realtime

SSE from the gateway: a per-channel stream for open channels and a per-space activity stream for badges; reconnect with Last-Event-ID. Cloud Run: min instances 1, CPU always allocated, request timeout at the ceiling; the client expects drops on instance recycle and resumes from its marker.

Connectors

Every connector is the same two halves: an inbound receiver turning the world’s event into PostSystemMessage / UpsertObjectThread, and (later) an outbound action performed in the source tool.

Connector Inbound (v1) Outbound (v1.5+)
GitHub the wardroom GitHub App’s webhook: push, PR opened/review/merged, issue, check failed, release → a #<repo> channel per repo, one thread per PR/issue comment / approve / merge via latchkey’s GitHub proxy (X-Latchkey-As: installation:&#123;id&#125;) — the App key lives in latchkey, never here
purser — tickets purser’s outbound webhook: TicketOpened, MessageAdded, StatusChanged#support thread per ticket; the customer’s messages rendered distinctly Reply to customer as an explicit action via token exchange. A plain thread message is always internal.
purser — leads LeadCreated, StageChanged, ActivityLogged#leads thread per lead, stage on the card move stage / assign / log a visit — the field rep’s phone flow
tripline its existing webhook alert channel, verified with X-Tripline-Signature#alerts thread per issue resolve / ignore
runsheet / chippy deploy started / finished / failed → #deploys
foghorn campaign sent / report ready → #marketing
generic a signed incoming webhook per channel (Slack’s incoming-webhook model): Stripe, Cloud Run, anything

Acting in another tool on behalf of a person (v1.5)

A person’s wardroom token carries only wardroom namespaces, so purser’s API will not accept it. The built mechanism is Latchkey’s RFC 8693 token exchange: wardroom’s confidential client exchanges the person’s token for aud=purser, sub = the person, act.sub = wardroom’s client id. Purser accepts that audience, trusts the actor, and authorizes the sub from its own membership knowledge. Every such action is a journaled effect (loom.Once): a customer reply is sent once even if wardroom retries. Until v1.5, cards deep-link and nothing is executed here.

4. V1 scope (one line each)

  1. Spaces from Latchkey tenants; sign-in, signup, invitations — the tripline console flow verbatim (/o/:org, /welcome, /invitations, /platform/orgs for staff).
  2. Channels: create, rename, archive; #general per space.
  3. Messages: post, edit, delete, react; threads; file attachments.
  4. Timeline over SSE; unread per channel; mentions → notification.
  5. Search over MessageRow.
  6. GitHub connector (inbound only) and the generic signed webhook — so tripline alerts land on day one.
  7. Deploy: Cloud Run + Postgres + app. / hooks. domain mappings + Latchkey org in Terraform.

Non-goals for v1: private channels, DMs, presence/typing, voice, in-thread actions, purser connector (needs purser’s webhooks to exist), mobile app, a customer widget.

5. Sequencing

  1. purser v1 first (recommended — it unblocks customer support and is the smaller job), then wardroom v1 per §4.
  2. wardroom v1.5: purser connector (tickets, then leads) + token exchange for reply-to-ticket and move-lead-stage.
  3. Mobile (Expo) for grapevine’s field reps once #leads works.

The reverse order is defensible if the team’s coordination is the bigger pain than customer email today.

6. Open questions (decide at build start)

  • The roster problem: every house service is one more tenant every teammate must be invited to. Treat the wardroom space as the team roster and mirror grants outward (grapevine’s mirrorLatchkey precedent), or lean on latchkey SCIM. Not v1’s problem, but the answer changes whether Space carries a member list.
  • Search: tsvector side index on MessageRow vs a dedicated projection. Decide when writing the table.
  • Files: loom uploads to a GCS bucket (latchkey-style) — confirm bucket-per-service in infra/gcp.
  • Mail provider for digests: SendGrid via the wardroom Latchkey org’s sender config (the org-settings ladder in latchkey’s ROADMAP), not an env credential.