Docs
Design
foghorn — outbound marketing — the design document, verbatim.
Design doc, 2026-09-14. Status: agreed in shape, not yet built; last of the three to build. The reasoning and the alternatives are in wardroom/docs/ANALYSIS-2026-09-14.md; this file is the part to build from.
1. Why this exists
Marketing is already happening without a tool: 1099-w9/marketing
holds an email sequence, LinkedIn posts and ad copy as files; grapevine
has posters, QR codes and credit offers; every product has a site. What
is missing is one ledger of what we ran, to whom, and what it produced
— across products, with the compliance floor that bulk email in two
jurisdictions requires. Internal-first, like the rest; sellable later
only if it earns it.
2. Decisions locked (2026-09-14)
- Own repo, own loom service, own DB, own Cloud Run — the tripline template. Separate from purser because bulk-send volume, social tokens and the compliance surface are a different beast from the customer record; separate from wardroom because it is outbound.
- A workspace is a Latchkey tenant, one per product, namespace
foghorn/<slug>, the same slugs purser and wardroom use. Each workspace has its own sending domain (SPF/DKIM/DMARC), sender credentials and consent policy — inflow sends under CAN-SPAM, grapevine under the AU Spam Act, and they never share a list. - Foghorn never owns contacts. Audiences are read from purser; engagement is written back to purser. Segments are definitions here, resolved against purser at send time.
- Transactional mail stays in the products. Foghorn is broadcast and sequences only. Suppression lists are per workspace and never consulted by a product’s transactional sender.
- PostHog stays. Foghorn reads it over HogQL (lift grapevine’s
external/posthog) for visits and signups; it does not ingest behavioural events. - Social is plan-and-track in v1, not publish. Publishing APIs are
an approvals problem (Meta app review and 60-day tokens, LinkedIn
Marketing Developer Platform, X’s paid API, TikTok’s audit), not a
code problem. A planned
Postand a deliveredPostare different aggregates; decide publishing (v2, or buy Buffer per channel) before adding the second. - grapevine’s
CreditCampaignis not this. A foghorn Campaign may reference a product’s offer; it never owns one. - Compliance floor is non-negotiable before the first bulk send:
RFC 8058 one-click unsubscribe (
List-Unsubscribe+List-Unsubscribe-Post), a recorded consent basis per contact (from purser), physical address and identification in every send, a per-workspace sending domain with authentication.
3. Shape
purser audiences ──→ resolve segment ──→ send job (batched loom.Once effects) ──→ SendGrid
SendGrid events ──→ hooks.foghorn.id ──→ engagement fold ──→ purser (write-back)
purser outcomes ──→ hooks.foghorn.id ──→ attribution fold ──→ CampaignReport
PostHog (HogQL) ──→ visits / signups per utm ─┘
/l/{code} ──→ redirect + UTM stamp + click event
Domain (loom, first cut)
record Workspace— slug, sending domain, sender config (sealed@secret, KMS — latchkey’s per-org SendGrid pattern), consent policy, postal address for the footer.aggregate Campaign— name,channel: email | social | ads | print | referral, period, budget,product_offer_ref(optional, e.g. a grapevineCreditCampaignid), goal, status. Commands: Create, Update, Launch, Close.aggregate TrackedLink—code, destination, UTMs, campaign; the/l/{code}mount records a click event and redirects (grapevine’s QR redirect is the same thing).aggregate EmailSend— a broadcast: campaign, segment definition, template, schedule; the send process resolves the audience from purser, filters againstSuppression, and fans out oneloom.Onceeffect per recipient (tripline’sdeliverpattern — retries, dead letters, never a duplicate). Per-recipient delivery state folds from the SendGrid event webhook (latchkey’smailwebhook.go, keeping open/click).aggregate Sequence+record Enrolment— an ordered set of sends with delays; an enrolment is a timer chain per contact (LoginTicketexpiry is the timer precedent); exit on reply/convert/unsubscribe.record Suppression(workspace, email) — unsubscribed, bounced, complained; written from the webhook and the unsubscribe endpoint, consulted by every send, mirrored to purser as consent state.aggregate Post— social, plan-and-track: channel, copy, assets, scheduled time, approval, tracked link, pasted or pulled metrics.CampaignReport— the attribution projection: reach (sends / impressions) → engagement (opens / clicks) → conversion (attributed signups via PostHog utm + purserLeadCreated{campaign_id}) → revenue (purserDealWon{value}; a product’sRevenueRecordedwhere it carries a campaign). First-touch and last-touch both kept.
Seams
| Direction | Mechanism | Payload |
|---|---|---|
| purser → foghorn | paged read as foghorn’s service client | audience: contact id, email, consent basis, workspace |
| foghorn → purser | idempotent write per SendGrid event id, as purser’s service client | delivered / opened / clicked / bounced / unsubscribed per contact |
| purser → foghorn | signed webhook | LeadCreated{source, campaign_id}, DealWon{value} |
| SendGrid → foghorn | signed event webhook (ECDSA, latchkey’s verifier) | delivery and engagement events |
| foghorn → wardroom | signed webhook | campaign launched / send finished / report ready → #marketing |
| products → foghorn | nothing | products carry UTMs to PostHog; foghorn reads them there |
4. V1 scope (one line each)
- Workspaces with sending domain + sender config + consent policy.
- Campaign ledger and tracked links with the
/l/{code}redirect. - Email broadcasts: segment from purser, template, schedule, batched sends, per-recipient state from the SendGrid webhook, suppression, one-click unsubscribe.
- Social calendar as plan-and-track with a tracked link per post.
CampaignReportover links + sends + PostHog + purser webhooks.- Deploy: Cloud Run + Postgres +
app./hooks.domain mappings + Latchkey org in Terraform.
Non-goals for v1: sequences, social publishing, ad-platform spend import, landing pages or a funnel builder (the product sites are the pages), push campaigns to app users (a product feature), app-install attribution, A/B testing.
5. Sequencing
After purser (audiences and outcomes come from it) and wardroom v1. Then: sequences; social publishing (LinkedIn, Meta) once approvals are through, or buy; Google/Meta Ads spend as an effect.
6. Open questions (decide at build start)
- SendGrid vs Postmark for broadcast: SendGrid is what the estate runs and has the event-webhook code; Postmark is the purser leaning for inbound. Broadcast stays SendGrid unless deliverability says otherwise — but each workspace’s sender is its own config, so this can differ per product.
- Segment language: a small typed filter over purser’s contact fields (stage, tags, product, last engaged), resolved at send time — not a query DSL.
- Where the unsubscribe page lives:
foghorn.id(one page, branded per workspace) vs the product’s domain. Deliverability prefers the sending domain; decide with the sending-domain setup. - Report definition of “success” per campaign type — write it down before the dashboard exists.