Purser
Docs navigation

Docs

Design

purser — the customer record: support, contacts, leads — the design document, verbatim.

Design doc. The 2026-08-31 support design (below, §1–§9) stands as the v1 ticket model. Amendments of 2026-09-14 first; the reasoning is in wardroom/docs/ANALYSIS-2026-09-14.md.

0. Amendments, 2026-09-14

Name: purser (purser.id, GitHub purserid/purser, Latchkey org purser). On a ship the purser’s office is guest services — complaints, questions, accounts — and the purser keeps the manifest and the money. That is this service: tickets, contacts, leads, the pipeline. Closes §9’s first question. Customers never see the name; support@<product-domain> stays the address.

Scope grows from “support” to “the customer record.” Whichever service owns Contact/Company is the CRM; foghorn needs an audience with its consent state, wardroom needs to know who is writing, sales needs the record. Purser owns all three. grapevine’s retired Supabase operations.* schema (companies, contacts with salesperson attribution and referral codes, an 8-stage pipeline, deals, activities, partners with commission rates and payments) is the requirements document for the CRM half; its portal admin pages are dead stubs marked TODO(crm-v2) waiting on this.

Four concrete changes to the design below:

  1. Contact is a day-one aggregate, not a “later enrichment” (§2.5). OpenTicket ensures a Contact by email. The contact id is the same deterministic derivation Latchkey uses for sub (uuid5 of the email), so a lead converges with the person’s identity the day they sign up — no link table. Company is a record contacts reference.
  2. Every ticket and lead state change is also an outbound webhook (TicketOpened, MessageAdded, StatusChanged, LeadCreated, StageChanged, ActivityLogged, DealWon) — signed, latchkey’s WebhookEndpoint/WebhookDelivery shape — so wardroom can mirror tickets and leads as threads and foghorn can fold outcomes onto campaigns, without purser knowing either exists.
  3. A ticket and a lead are one aggregate — a Conversation — differing in participants (members; a contact; a contact + company), transport (in-app; email in/out) and extra state (status for support, stage for leads). Internal note vs customer-visible reply is a visibility field on the message, never a separate command path. The v1 Ticket in §3 is the support-kind Conversation; nothing in it changes except the name and the seam for kind: lead.
  4. Leads and the pipeline are v2 (tranche two), not v1: Deal stage machine (lead → qualified → demo → proposal → negotiation → won / lost / churned — grapevine’s stages, the JobMatch shape), activities, follow-up timers, per-rep attribution for grapevine’s field sales, partners/referral codes/commissions if that program is coming back (decide explicitly; it is the largest chunk of the old schema). Lead capture arrives from product websites through a public endpoint with a latchkey publishable key, and from products as their service client (AccountEstablished, first-paid).

The staff inbox stays (§3) — it is the fallback and the admin surface. Day to day the team sees tickets and leads in wardroom; wardroom acts on them here (reply to customer, move stage) via Latchkey token exchange from its v1.5, every such action a journaled effect.

Tenancy is unchanged (§2.2): a workspace per product, namespace = workspace slug, the same slugs wardroom and foghorn use.

Sequencing is unchanged (§7): purser v1 is still the short job and still first — it unblocks customer support and nothing in it waits on wardroom or foghorn.


support — shared customer-support service (2026-08-31)

Design doc, 2026-08-31. Status: agreed, not yet built. Build starts after the optrader v1→v2 cutover. Directory name was a working name — resolved above.

1. Why this exists

Every product in the portfolio — optrader, Latchkey, price-sync, ten99, freeloot, … — needs customer support, and none of them should grow its own ticket system. This is the Latchkey move repeated: internal-first shared infrastructure on the house stack (loom + Latchkey), promoted to a sellable product later only if it earns it. It is explicitly NOT an attempt to compete with Zendesk/Intercom on day one; the customer is us.

The payoff over a SaaS helpdesk: one unified inbox across every product with staff auth we already operate, per-product tenancy that matches how everything else here works, an event-sourced audit trail for free, and no per-seat rent.

2. Decisions locked (2026-08-31)

  1. Own repo, own loom service, own DB, own Cloud Run service — the Latchkey deployment template, not a module inside any product’s backend. The products are peers.
  2. Tenancy: each product is a workspace (optrader, latchkey, ten99, …). Every ticket belongs to exactly one workspace.
  3. Staff auth rides the Latchkey org exactly like optrader’s admin dashboard: auth-code + PKCE public client, cookie session, staff gate. One sign-in, every product’s queue, filterable by workspace.
  4. Email-first intake. support@<product-domain> → inbound-mail webhook → ticket. Replies go out by email from the same address. Onboarding a product to support = DNS/MX + a workspace row, zero SDK/integration work in the product.
  5. Requesters are emails, not accounts. v1 optrader’s schema already learned this (account_or_email_required): support cannot assume the person has an account anywhere. An account/sub link is an optional later enrichment, never a requirement.
  6. Event-sourced from day one (loom). Status history, SLA analytics, and any future views are projections over events, not schema work.
  7. Cut from v1 scope: categories, assignment (assigned_to), priorities-as-workflow. One human answers everything; a ticket knows what it’s about. Priority survives only as an optional flag if it’s free to carry.
  8. In-app thread views come later, per product, as API consumers (tranche two) — e.g. optrader’s /[slug]/manage/support equivalent. Only where a product actually earns it.

3. Shape of the service

                        ┌─────────────────────────────┐
  support@optrader...   │  support (loom service)     │
  support@latchkey...  ─┼→ inbound webhook            │
  support@ten99...      │    → Ticket aggregate       │──→ outbound reply
                        │  staff inbox (admin-style   │    (same address)
      staff browser  ──→│  cookie session, Latchkey)  │
                        └─────────────────────────────┘

Domain (loom schema, first cut)

  • record Workspaceslug (= product), name, inbound_address (support@optrader.com.au), outbound_from, product_url. Records converge; adding a product is one upsert.
  • aggregate Ticket @snapshot — workspace ns or workspace field (decide against loom namespace-per-workspace early; leaning namespace = workspace slug to mirror optrader’s trader-ns pattern and keep per-product isolation idiomatic):
    • state: requester_email, requester_name, subject, status (open | waiting_customer | resolved | closedin_progress from v1 collapses into open; the answering human doesn’t need a second “I’m on it” state), messages: [TicketMessage] (embedded, the SaleTicket precedent — events carry the full updated list), source (email | api), timestamps, linked_sub (optional Latchkey sub, later).
    • commands: OpenTicket (webhook), AddInboundMessage (webhook, reply-to-thread), ReplyToTicket @role(staff) (side effect: outbound mail — via loom effects/outbox so send-exactly-once survives retries), SetTicketStatus @role(staff), ReopenTicket (inbound mail on a resolved ticket reopens it).
    • TicketRow @table for the inbox list (status, workspace, requester, subject, last-activity, message count).
  • Threading: outbound mail carries Message-ID/References plus a plus-addressed reply-to (support+<ticket-id>@…); inbound webhook resolves ticket by plus-address first, In-Reply-To second, (workspace, requester_email, open ticket) heuristic last.
  • Attachments: v1 carried jsonb attachments. Store provider URLs in v1 scope (Postmark keeps them ~45 days); proper blob storage (R2) is a fast-follow before anything retention-sensitive.

Staff inbox

The optrader admin-dashboard pattern verbatim (it’s proven and a day’s work): Go handler, embedded static HTML, cookie session signed with a local HMAC key, Latchkey public-client PKCE dance, staff-only. Pages: inbox (open tickets across workspaces, filter chips per workspace + status), ticket (thread + reply box + status buttons). No framework, no build step.

Mail provider

Recommendation: Postmark — first-class inbound parsing (JSON webhook with parsed thread + attachments), excellent transactional deliverability, per-server message streams that map neatly onto workspaces. Alternatives considered: SES (cheapest, but inbound is raw MIME + S3 assembly work), Resend (nice DX, inbound newer/less proven). Cost at our volume is noise; deliverability of replies is the thing that matters. Open until build (§9) but pick before writing the webhook — the payload shape leaks into the code.

4. What v1 (optrader/Supabase) got right — carry it over

From support schema, migration 20260201000600:

  • ticket_status lifecycle incl. waiting_customer — keep (minus in_progress, see above).
  • Requester = account or email (CHECK constraint) — keep as email-primary (decision 5).
  • Messages as first-class rows with attachments — keep (embedded).
  • Categories, priority, assigned_to — drop from v1 scope (decision 7).

There is no data migration: v1 optrader support tickets stay in the v1 snapshot archive. The new service starts empty.

5. V1 scope (the whole thing, one line each)

  1. Workspace records + seed for the current products.
  2. Inbound webhook → open ticket / append message / reopen.
  3. Staff inbox: list, thread, reply (sends mail), set status.
  4. Outbound reply mail with correct threading headers.
  5. Latchkey staff sign-in (admin-dashboard pattern).
  6. Deploy: Cloud Run + Postgres + domain (e.g. support.<house-domain> for the inbox UI; inbound addresses live on each product’s domain).

Non-goals for v1: customer-facing portal, in-app widgets/API consumers, categories/tags, assignment, SLA timers, canned replies, CSAT, knowledge base, AI drafting. All of these are projections or consumers a later tranche can add without schema regret.

6. Integration contract (per product, later tranches)

When a product wants an in-app thread view: authenticated GraphQL on this service — myTickets(workspace) for the signed-in requester (matched by verified email / linked sub), openTicket, replyToTicket. The product passes its user’s Latchkey token; this service verifies against the same issuer. Nothing in v1 blocks this; linked_sub and source: api are the seams.

7. Sequencing

  1. Now: nothing to build. Optionally claim the mail plumbing — create the Postmark account, point support@optrader.com.au forwarding somewhere safe so nothing ever bounces (an hour, not a project).
  2. After optrader cutover (the current critical path — rehearsal → cutover — stays clean): build v1 per §5. Estimate: one to two weeks, the mail plumbing being most of the unknown.
  3. First consumers: optrader (replaces the v1 support gap), Latchkey (support@latchkey.id), then the rest as workspace rows.

8. Promotion-to-product test (write it down so future-us is honest)

Revisit selling it only when all three hold: (a) it has run our own support for months without babysitting, (b) an outsider has asked for it, (c) there’s a differentiator story — “support for Latchkey-stack apps: tenant-aware, claims-driven, self-hostable” — not “cheaper Zendesk”. Until then it stays internal infrastructure.

9. Open questions (decide at build start)

  • Name (this repo dir support is a placeholder). House style is lowercase evocative nouns (latchkey, loom, grapevine).
  • Mail provider — Postmark recommended above; confirm.
  • Inbox UI host + house domain for the staff inbox.
  • Workspace-as-namespace vs field — leaning namespace (§3); confirm against loom multi-tenant ergonomics when writing the schema.
  • Where staff-ness lives — reuse the existing org’s staff tenant (one gate for everything) vs a support-specific tenant. Leaning: reuse.