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:
- Contact is a day-one aggregate, not a “later enrichment” (§2.5).
OpenTicketensures a Contact by email. The contact id is the same deterministic derivation Latchkey uses forsub(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. - Every ticket and lead state change is also an outbound webhook
(
TicketOpened,MessageAdded,StatusChanged,LeadCreated,StageChanged,ActivityLogged,DealWon) — signed, latchkey’sWebhookEndpoint/WebhookDeliveryshape — so wardroom can mirror tickets and leads as threads and foghorn can fold outcomes onto campaigns, without purser knowing either exists. - 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 (
statusfor support,stagefor leads). Internal note vs customer-visible reply is avisibilityfield on the message, never a separate command path. The v1Ticketin §3 is the support-kind Conversation; nothing in it changes except the name and the seam forkind: lead. - Leads and the pipeline are v2 (tranche two), not v1:
Dealstage machine (lead → qualified → demo → proposal → negotiation → won / lost / churned — grapevine’s stages, theJobMatchshape), 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)
- 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.
- Tenancy: each product is a workspace (
optrader,latchkey,ten99, …). Every ticket belongs to exactly one workspace. - 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.
- 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. - 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. - Event-sourced from day one (loom). Status history, SLA analytics, and any future views are projections over events, not schema work.
- 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. - In-app thread views come later, per product, as API consumers
(tranche two) — e.g. optrader’s
/[slug]/manage/supportequivalent. 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 Workspace—slug(= product),name,inbound_address([email protected]),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 | closed—in_progressfrom v1 collapses intoopen; 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 @tablefor the inbox list (status, workspace, requester, subject, last-activity, message count).
- state:
- Threading: outbound mail carries
Message-ID/Referencesplus a plus-addressed reply-to (support+<ticket-id>@…); inbound webhook resolves ticket by plus-address first,In-Reply-Tosecond, (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_statuslifecycle incl.waiting_customer— keep (minusin_progress, see above).- Requester = account or email (
CHECKconstraint) — 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)
- Workspace records + seed for the current products.
- Inbound webhook → open ticket / append message / reopen.
- Staff inbox: list, thread, reply (sends mail), set status.
- Outbound reply mail with correct threading headers.
- Latchkey staff sign-in (admin-dashboard pattern).
- 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
- Now: nothing to build. Optionally claim the mail plumbing —
create the Postmark account, point
[email protected]forwarding somewhere safe so nothing ever bounces (an hour, not a project). - 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.
- First consumers: optrader (replaces the v1 support gap), Latchkey ([email protected]), 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
supportis 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.