Developer Handoff · For Devin

Capture everything.
Issue TCID. Route every email.

Spec for wiring the M5 marketing site to Supabase: every form on every page, a single inbox for all human contact, and the waitlist pipeline that mints a placeholder TCID + block account for each reservation.

Status · Ready to build Stack · Supabase + Resend Routing inbox · TitleChain Foundation
01 · Scope

Goals & non-goals.

Frame the work before the schema. This is a marketing-site capture layer, not the production IAM/ledger.

In scope

Out of scope (for now)

02 · Email Consolidation

One inbox. Zero exposed addresses.

Every email on the site today is either a bare mailto: link or visible plaintext. Both are scraped within hours of indexing. We're consolidating all 9 addresses into a single routed inbox and replacing every visible email with a contact form.

Addresses currently on the site

How to remove them from the DOM

  1. Replace every <a href="mailto:…">email@addr</a> with a contact button that opens an inline mini-form (name, email, message, plus a hidden channel field that records which address it was meant for).
  2. Replace plaintext addresses on contact.html with the channel label only ("Office of the Founder · letters →") and the same button-to-form pattern.
  3. Delete the bare ch-value divs that show the addresses; keep ch-tag + ch-name + ch-desc.
  4. Update email-templates.html — these are visual mockups of outbound mail; remove the rendered "from" addresses so screenshots don't leak any address.

Inbound routing (Resend or Postmark)

Set up a single verified sending domain (titlechainfoundation.org). Submit-handler edge function writes to Supabase and emails the team via Resend using the Foundation no-reply sender, the Foundation inbox as the recipient, and reply-to: {user's email}. The team replies once, the user receives it from the Foundation address. No staff inbox is exposed.

Why one inbox. Cuts deliverability surface to a single SPF/DKIM/DMARC config. Lets us add Front, Help Scout, or Missive later without rewiring the site. Channel routing happens in the helpdesk, not in the DOM.
03 · Supabase Schema

Tables & DDL.

Six tables. All keyed on UUID, all RLS-locked to the service role (no client reads). Submissions append-only; contacts dedupes on lower(email).

-- 1. Canonical contact record. One row per unique email.
create table contacts (
  id            uuid primary key default gen_random_uuid(),
  email         text not null unique,         -- normalized lower()
  display_name  text,
  legal_name    text,
  country       text,
  jurisdiction  text,
  org_name      text,
  org_type      text,
  role          text,
  phone         text,
  source_page   text,                          -- first page they hit
  utm           jsonb,
  created_at    timestamptz default now(),
  updated_at    timestamptz default now()
);
create index on contacts (lower(email));

-- 2. Waitlist / reservation — the WSW Activation form. Issues TCID.
create table reservations (
  id                  uuid primary key default gen_random_uuid(),
  contact_id          uuid references contacts(id) on delete cascade,
  tcid                text unique not null,    -- M5HUM-000001 etc
  block_account       text unique not null,    -- @handle.root.nation
  full_legal_name     text,
  display_name        text,
  email               text not null,
  country             text,
  jurisdiction        text,
  claiming_for        text,                    -- Myself | Family | Business | Nation
  m5_handle           text,
  primary_root        text default 'm5bom',
  additional_vaults   text[],                  -- [m5bof, m5bou, m5bob, ...]
  ext_nation_chain    boolean default true,
  ext_region_chain    boolean default true,
  ext_city            text,
  linked_domains      jsonb,                   -- [{type:'eth', value:'vitalik.eth'}, ...]
  entry_door          text,                    -- Door 01 / 02 / 03
  first_lock          text[],
  credentials         text[],
  cohort              text[],
  consent             text[],
  status              text default 'reserved', -- reserved | verified | activated
  reserved_at         timestamptz default now()
);

-- 3. IAM Activation — the email/phone + state-chain capture from iam-activate.html
create table iam_activations (
  id            uuid primary key default gen_random_uuid(),
  contact_id    uuid references contacts(id) on delete cascade,
  contact_input text not null,                 -- email OR phone, raw
  channel       text,                          -- 'email' | 'phone'
  state_chain   text,                          -- e.g. 'wyoming.us'
  chain_kind    text,                          -- state | tribe | territory | nation
  civic_notice_date date default '2026-07-04',
  status        text default 'pending',
  created_at    timestamptz default now()
);

-- 4. Circle applications — private invitation form from circle.html
create table circle_applications (
  id           uuid primary key default gen_random_uuid(),
  contact_id   uuid references contacts(id) on delete cascade,
  name         text,
  email        text,
  entity       text,
  role         text,
  capital_type text,                           -- Sovereign Wealth Fund | Family Office | ...
  region       text,
  why          text,
  status       text default 'submitted',       -- submitted | reviewing | invited | declined
  created_at   timestamptz default now()
);

-- 5. Learn track waitlist — learn.html
create table learn_waitlist (
  id          uuid primary key default gen_random_uuid(),
  contact_id  uuid references contacts(id) on delete cascade,
  email       text not null,
  role        text,
  area        text,                            -- Legacy systems | Sovereign infrastructure | ...
  created_at  timestamptz default now()
);

-- 6. Generic contact messages — replaces every mailto: on the site
create table contact_messages (
  id          uuid primary key default gen_random_uuid(),
  contact_id  uuid references contacts(id) on delete cascade,
  name        text,
  email       text not null,
  channel     text not null,                   -- founder | press | circle | sovereign | partners | wsw | general
  subject     text,
  message     text not null,
  source_page text,
  user_agent  text,
  ip_hash     text,                            -- sha256(ip + daily salt) for rate limit
  status      text default 'new',              -- new | answered | spam
  created_at  timestamptz default now()
);

-- TCID sequence — one source of truth for the M5HUM-XXXXXX numbers
create sequence tcid_seq start 1000;

-- RLS: deny all by default. Edge function uses service-role key.
alter table contacts            enable row level security;
alter table reservations        enable row level security;
alter table iam_activations     enable row level security;
alter table circle_applications enable row level security;
alter table learn_waitlist      enable row level security;
alter table contact_messages    enable row level security;
Why six tables, not one. Each form has a distinct downstream — reservations get TCIDs, Circle applications get human review, Learn waitlist gets cohort emails, contact messages get triaged. One denormalized "submissions" table looks tidy at first and becomes a query nightmare by month three. contacts is the join key.
04 · Form-by-form

What we capture, everywhere.

Each form on the site, with its target table and exact field map. Every form also writes/updates a contacts row keyed on email.

Source · wsw-activation.html · PRIMARY WAITLIST
Reservation form → reservations

The big multi-step "Reserve your block" form. This is where we issue a TCID and a block account name. See section 05.

DOM nameSupabase columnNotes
fullLegalNamefull_legal_nameRequired for TCID issuance
displayNamedisplay_namePreferred name shown in UI
emailemailrequired · unique key
countrycountryHidden input; populated by custom dropdown. Drives nation chain.
jurisdictionjurisdictionFree-text region (Wyoming, Zürich, Lagos)
claimingForclaiming_forMyself · Family · Business · Nation
addVault[]additional_vaultsCheckbox group: m5bof, m5bou, m5bob, m5boi, m5boc
m5Handlem5_handleUser's chosen handle. Validate /^[a-z0-9-]{2,32}$/
extNationext_nation_chainBoolean checkbox
extRegionext_region_chainBoolean checkbox
extCityext_cityOptional city slug
legacyInput (dynamic)linked_domainsArray of {type, value} — ENS, email, phone, Discord
entryDoorentry_doorRadio: Door 01 / 02 / 03
firstLock[]first_lockCheckbox group from Step 4
credentials[]credentialsCheckbox group from Step 5
cohort[]cohortCheckbox group from Step 6
consent[]consentFinal consent checkboxes
Source · iam-activate.html
IAM Activation → iam_activations
DOM idSupabase columnNotes
contact-inputcontact_inputEmail OR phone. Detect format, write to channel = 'email' or 'phone'.
cs-search (selected)state_chainFrom custom chain selector. Store as wyoming.us format.
[data-kind]chain_kindstate · tribe · territory · nation
Source · circle.html
Private Circle application → circle_applications
DOM idSupabase columnNotes
f-namename
f-emailemailrequired
f-entityentityFund, foundation, family office
f-rolerole
f-typecapital_typeSovereign Wealth Fund · Family Office · Foundation · etc.
f-regionregionNorth America · Europe · APAC · etc.
f-whywhyOpen textarea. Trim, max 4000 chars.
Source · learn.html
Learn track waitlist → learn_waitlist
DOM idSupabase columnNotes
wl-emailemailrequired
wl-rolerole
wl-areaareaLegacy systems · Sovereign infrastructure · Agentic AI · etc.
Source · bom-onboard.html & onboard-business.html
Onboard intro → contacts (lightweight)

Currently captures just first name (Onboard) or org name + type (Onboard_Business). Treat as a pre-form lead — write to contacts with source_page='Onboard'. Don't create a reservation until email is captured.

DOM idSupabase columnNotes
firstName (Onboard)display_namePersonal flow
firstName (Business)org_nameBusiness flow — stores as org_name
orgTypeorg_typeBusiness flow only
Source · contact.html + every replaced mailto:
Contact messages → contact_messages

The replacement for every mailto:. Single inline form pattern. The channel field tells helpdesk which address it was meant for so we can route on reply.

Form fieldSupabase columnNotes
namename
emailemailrequired
messagemessagerequired · max 4000
channel (hidden)channelfounder · press · circle · sovereign · partners · wsw · general
subject (optional)subjectDerived from channel if blank
— auto —source_pagewindow.location.pathname
— auto —user_agent / ip_hashFor rate limiting only
05 · Issuance

TCID + block account the moment they reserve.

When the WSW Activation form submits, the edge function does four things atomically inside a single Postgres transaction.

  1. Upsert contacts on lower(email).
  2. Reserve the handle. If m5_handle is taken, append a 4-digit suffix and return both options to the client.
  3. Mint the TCID. SELECT nextval('tcid_seq'), format as M5HUM-{000000-padded}.
  4. Compose the block account string. See grammar below. Write to reservations.block_account.

TCID format

// 6-digit zero-padded, prefixed M5HUM- for personal door
// Door 02 (business) uses M5BIZ-, Door 03 uses M5BLD-
function formatTcid(seq, door) {
  const prefix = door.startsWith('Door 01') ? 'M5HUM'
              : door.startsWith('Door 02') ? 'M5BIZ'
              : 'M5BLD';
  return `${prefix}-${String(seq).padStart(6, '0')}`;
}
// Examples: M5HUM-001000, M5BIZ-001042, M5BLD-001137

Block account grammar

Composed from handle + country + region + optional city, gated by the ext_* booleans the user toggled.

// All slugified to [a-z0-9-]
@{handle}.{primary_root}                                // always
@{handle}.{primary_root}.{nation}                       // if ext_nation_chain
@{handle}.{primary_root}.{nation}.{region}              // if ext_region_chain
@{handle}.{primary_root}.{nation}.{region}.{city}       // if ext_city present

// Example for pamela in Wyoming, US, Cheyenne, all extensions on:
@pamela.m5bom.us.wyoming.cheyenne

Returned to the client

The submit response includes everything the user needs to see their certificate render on the success screen:

{
  "tcid": "M5HUM-001000",
  "block_account": "@pamela.m5bom.us.wyoming",
  "door": "Door 01 · IAM / Bank of Me",
  "reserved_at": "2026-05-20T18:42:11Z",
  "civic_notice_date": "2026-07-04",
  "status": "reserved"
}
Race conditions. The TCID sequence is safe (Postgres handles concurrency). The handle is not — wrap "check + insert" in a transaction with a unique constraint on reservations.m5_handle. On conflict, fail loudly and ask the user to pick another.
Reserved ≠ minted. TCIDs issued here are provisional — they're reserved against the legal name and email, but only become live on-chain when the user completes IAM verification post-mainnet. Status moves reserved → verified → activated.
06 · Edge Function

The single submit handler.

One Supabase Edge Function — POST /functions/v1/submit — handles every form. Dispatch on a form field in the body. Centralizes validation, rate limiting, contact upsert, and Resend dispatch.

// supabase/functions/submit/index.ts
import { createClient } from '@supabase/supabase-js';
import { Resend } from 'resend';

const sb = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
const resend = new Resend(Deno.env.get('RESEND_API_KEY')!);

const HANDLERS = {
  reservation:   handleReservation,    // → reservations + TCID
  iam:           handleIamActivation,  // → iam_activations
  circle:        handleCircle,         // → circle_applications
  learn:         handleLearn,          // → learn_waitlist
  contact:       handleContact,        // → contact_messages
  onboard:       handleOnboard,        // → contacts only
};

Deno.serve(async (req) => {
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });

  const body = await req.json();
  const handler = HANDLERS[body.form];
  if (!handler) return json({ error: 'Unknown form' }, 400);

  try {
    await rateLimit(req);                        // 5/min per IP hash
    const contact = await upsertContact(body);    // always
    const result  = await handler(body, contact);
    await notifyTeam(body.form, result, body);     // → Foundation inbox (FOUNDATION_INBOX env)
    return json(result);
  } catch (err) {
    return json({ error: err.message }, err.status ?? 500);
  }
});

The notify function

async function notifyTeam(form, result, payload) {
  await resend.emails.send({
    from:     FOUNDATION_FROM,   // env: Foundation no-reply sender
    to:       FOUNDATION_INBOX,  // env: Foundation routing inbox
    reply_to: payload.email,
    subject:  `[${form}] ${payload.name ?? payload.email}`,
    html:     renderEmail(form, result, payload),
  });
}
One env, one handler, one inbox. If you add a new form later, you add a row to HANDLERS and a table. The client wiring (next section) stays the same.
07 · Client Wiring

Drop-in submit helper.

One m5-submit.js file loaded on every page. Replaces the existing submitWaitlist(), submitCircle(), and submitReserve() functions, all of which currently do nothing but flip a CSS class.

// m5-submit.js — load on every page
const M5 = {
  endpoint: 'https://<project>.supabase.co/functions/v1/submit',
  anon:     '<public anon key>',

  async submit(form, data) {
    const res = await fetch(M5.endpoint, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'apikey':       M5.anon,
        'authorization': `Bearer ${M5.anon}`,
      },
      body: JSON.stringify({
        form,
        ...data,
        _source: location.pathname,
        _utm:    parseUtm(location.search),
      }),
    });
    if (!res.ok) throw new Error((await res.json()).error);
    return res.json();
  },
};
window.M5 = M5;

Per-page wire-up

Each form's existing submit function becomes a one-liner. Example for the Learn waitlist:

async function submitWaitlist() {
  const data = {
    email: f('wl-email'),
    role:  f('wl-role'),
    area:  f('wl-area'),
  };
  if (!data.email.includes('@')) return flashError('wl-email');
  try {
    await M5.submit('learn', data);
    showSuccess();
  } catch (e) { flashError('wl-email', e.message); }
}
08 · Build Order

Devin's checklist.

Build top-to-bottom. Each step is testable before moving on.

  1. Create Supabase project. Run the DDL from §03. Enable RLS. Generate service-role key.
  2. Verify titlechainfoundation.org in Resend. Add SPF, DKIM, DMARC records. Confirm hello@… is a real distribution group, not an alias.
  3. Deploy the edge function from §06. Test each form branch with curl. Confirm each writes to its table AND triggers a Resend email.
  4. Add m5-submit.js from §07 to m5-chrome.js bundle (already loaded everywhere).
  5. Rewire WSW Activation. Replace the no-op submitReserve(). On success, show the returned TCID + block_account on the existing success card.
  6. Rewire IAM_Activate, Circle, Learn, Onboard, Onboard_Business. Same pattern.
  7. Strip every mailto: from the site. Replace with a button that opens an inline contact form. Set the hidden channel field based on which address it replaced. Pages to touch:
    • contact.html — 6 instances
    • wsw-activation.html — 1 instance (sovereign vetting modal)
    • email-templates.html — visual mockups; remove rendered "from" addresses entirely
  8. Remove plaintext addresses from contact.html entity cards. Keep the role labels.
  9. Add a sitewide search for the string @m5capital.com, @m5bank.app, @titlechain.org, @icsn.global. None should remain in markup after this step.
  10. Smoke test. Submit every form. Confirm one row per table, one email per submission, both arrive in the Foundation routing inbox.
  11. Deliver: the Supabase project URL, the edge function source, and a one-page README with env vars and the schema diagram.
Ship order matters. Get steps 1–4 working before touching any HTML — that way wiring the forms is a 10-minute job per page instead of an afternoon of debugging the backend through the UI.