Foghorn
Docs navigation

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. PostHog stays. Foghorn reads it over HogQL (lift grapevine’s external/posthog) for visits and signups; it does not ingest behavioural events.
  6. 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 Post and a delivered Post are different aggregates; decide publishing (v2, or buy Buffer per channel) before adding the second.
  7. grapevine’s CreditCampaign is not this. A foghorn Campaign may reference a product’s offer; it never owns one.
  8. 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/&#123;code&#125;        ──→ 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 grapevine CreditCampaign id), goal, status. Commands: Create, Update, Launch, Close.
  • aggregate TrackedLinkcode, destination, UTMs, campaign; the /l/&#123;code&#125; 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 against Suppression, and fans out one loom.Once effect per recipient (tripline’s deliver pattern — retries, dead letters, never a duplicate). Per-recipient delivery state folds from the SendGrid event webhook (latchkey’s mailwebhook.go, keeping open/click).
  • aggregate Sequence + record Enrolment — an ordered set of sends with delays; an enrolment is a timer chain per contact (LoginTicket expiry 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 + purser LeadCreated&#123;campaign_id&#125;) → revenue (purser DealWon&#123;value&#125;; a product’s RevenueRecorded where 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&#123;source, campaign_id&#125;, DealWon&#123;value&#125;
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)

  1. Workspaces with sending domain + sender config + consent policy.
  2. Campaign ledger and tracked links with the /l/&#123;code&#125; redirect.
  3. Email broadcasts: segment from purser, template, schedule, batched sends, per-recipient state from the SendGrid webhook, suppression, one-click unsubscribe.
  4. Social calendar as plan-and-track with a tracked link per post.
  5. CampaignReport over links + sends + PostHog + purser webhooks.
  6. 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.