[object Object]

← back to Norma Platform

Add donation reconciliation slice (TK-11335 Section A, local-safe)

b18aac0ab0a5109842a78ada347b819bc266fb4c · 2026-09-09 15:59:52 -0700 · Steve Abrams

Links a pledge (giving intention) to the real donation that fulfills it, with
NO money movement. Reviewed by the contrarian (Cody) before commit; all three
of its FIX-FIRST findings folded in and re-verified.

- Migration 029: org-scoped organizing_donations ledger (cents), idempotent on
  (org, provider, provider_ref). A partial UNIQUE index on matched_participation_id
  makes one-time reconciliation race-proof (a pledge is fulfilled by at most one
  donation). Deliberately separate from the legacy non-tenant `donations` table
  to preserve tenant isolation.
- lib/campaigns/reconcile.ts: deterministic FIFO matcher for ONE-TIME giving
  (org + email + exact amount, pledge-before-donation), with a savepoint/23505
  catch so a concurrent race leaves a donation honestly unattributed rather than
  double-counting. Recurring (monthly) reconciliation is intentionally deferred
  to milestone 2 (one-to-many) — monthly donations show as unattributed, never
  falsely matched. ingestDonation is SANDBOX-ONLY (throws outside sandbox), uses
  the authenticated scope.org (never a payload org), and accepts a validated
  provider donated_at.
- GET /api/campaigns/reconcile + POST /donations/ingest; workspace reconcile
  totals; a "Giving reconciliation" Insights card; RUNBOOK boundaries updated.

Section B (real Stripe/ActBlue keys, live signature-verified webhook transport,
card collection/recurring billing/refunds/receipts, prod deploy) stays gated.

Verified on a fresh build: tsc clean, eslint 0 errors, 16/16 API journeys
(match, idempotency, one-to-one, monthly-deferred, donated_at, tenant-scope),
8/8 Chromium+WebKit browser, production-default gate (ingest 409 outside sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L

Files touched

Diff

commit b18aac0ab0a5109842a78ada347b819bc266fb4c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 15:59:52 2026 -0700

    Add donation reconciliation slice (TK-11335 Section A, local-safe)
    
    Links a pledge (giving intention) to the real donation that fulfills it, with
    NO money movement. Reviewed by the contrarian (Cody) before commit; all three
    of its FIX-FIRST findings folded in and re-verified.
    
    - Migration 029: org-scoped organizing_donations ledger (cents), idempotent on
      (org, provider, provider_ref). A partial UNIQUE index on matched_participation_id
      makes one-time reconciliation race-proof (a pledge is fulfilled by at most one
      donation). Deliberately separate from the legacy non-tenant `donations` table
      to preserve tenant isolation.
    - lib/campaigns/reconcile.ts: deterministic FIFO matcher for ONE-TIME giving
      (org + email + exact amount, pledge-before-donation), with a savepoint/23505
      catch so a concurrent race leaves a donation honestly unattributed rather than
      double-counting. Recurring (monthly) reconciliation is intentionally deferred
      to milestone 2 (one-to-many) — monthly donations show as unattributed, never
      falsely matched. ingestDonation is SANDBOX-ONLY (throws outside sandbox), uses
      the authenticated scope.org (never a payload org), and accepts a validated
      provider donated_at.
    - GET /api/campaigns/reconcile + POST /donations/ingest; workspace reconcile
      totals; a "Giving reconciliation" Insights card; RUNBOOK boundaries updated.
    
    Section B (real Stripe/ActBlue keys, live signature-verified webhook transport,
    card collection/recurring billing/refunds/receipts, prod deploy) stays gated.
    
    Verified on a fresh build: tsc clean, eslint 0 errors, 16/16 API journeys
    (match, idempotency, one-to-one, monthly-deferred, donated_at, tenant-scope),
    8/8 Chromium+WebKit browser, production-default gate (ingest 409 outside sandbox).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L
---
 app/api/campaigns/[[...path]]/route.ts             |   3 +
 components/campaigns/CampaignWorkspace.tsx         |   2 +-
 db/029_donation_reconciliation.sql                 |  33 ++++++++
 docs/platform/RUNBOOK.md                           |   3 +-
 lib/campaigns/reconcile.ts                         |  89 +++++++++++++++++++++
 lib/campaigns/store.ts                             |   5 +-
 lib/campaigns/types.ts                             |   2 +-
 scripts/campaign-preview.sh                        |   1 +
 scripts/test-instance.sh                           |   5 +-
 tests/campaign-disabled.mjs                        |   2 +
 tests/campaign-platform.mjs                        |  40 ++++++++-
 verification/platform/api-results.json             |   6 +-
 verification/platform/browser-results.json         |  36 ++++-----
 verification/platform/publication-results.json     |   6 +-
 .../platform/screenshots/chromium-advocacy.png     | Bin 102670 -> 103951 bytes
 .../platform/screenshots/chromium-campaign.png     | Bin 145602 -> 145920 bytes
 .../platform/screenshots/chromium-consent.png      | Bin 142866 -> 142916 bytes
 .../platform/screenshots/chromium-event.png        | Bin 108818 -> 109311 bytes
 .../platform/screenshots/chromium-fundraiser.png   | Bin 84637 -> 85534 bytes
 .../platform/screenshots/chromium-mobile.png       | Bin 940034 -> 1187989 bytes
 .../platform/screenshots/chromium-receipt.png      | Bin 91477 -> 92296 bytes
 .../platform/screenshots/chromium-volunteer.png    | Bin 96715 -> 97038 bytes
 .../platform/screenshots/webkit-advocacy.png       | Bin 334913 -> 344785 bytes
 .../platform/screenshots/webkit-campaign.png       | Bin 464877 -> 455830 bytes
 .../platform/screenshots/webkit-consent.png        | Bin 432134 -> 433570 bytes
 verification/platform/screenshots/webkit-event.png | Bin 356821 -> 365606 bytes
 .../platform/screenshots/webkit-fundraiser.png     | Bin 298397 -> 306556 bytes
 .../platform/screenshots/webkit-mobile.png         | Bin 2918553 -> 3639882 bytes
 .../platform/screenshots/webkit-receipt.png        | Bin 305728 -> 298943 bytes
 .../platform/screenshots/webkit-volunteer.png      | Bin 314359 -> 322088 bytes
 30 files changed, 207 insertions(+), 26 deletions(-)

diff --git a/app/api/campaigns/[[...path]]/route.ts b/app/api/campaigns/[[...path]]/route.ts
index e24a19e..1ac96c0 100644
--- a/app/api/campaigns/[[...path]]/route.ts
+++ b/app/api/campaigns/[[...path]]/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
 import { body, CampaignError, failure, publishingEnabled, sandbox, scope } from '@/lib/campaigns/validation';
 import { attendance, createAction, createCampaign, detail, saveTask, updateAction, updateCampaign, workspace } from '@/lib/campaigns/store';
 import { consentDetail, dispatchConfirmation } from '@/lib/campaigns/consent';
+import { ingestDonation, reconcileReport } from '@/lib/campaigns/reconcile';
 import { CONSENT_STATE_CSV, type ConsentState } from '@/lib/campaigns/types';
 import { query } from '@/lib/db';
 export const dynamic = 'force-dynamic';
@@ -20,6 +21,7 @@ export async function GET(request: NextRequest, context: Context) {
       return new NextResponse(csv,{headers:{'Content-Type':'text/csv; charset=utf-8','Content-Disposition':'attachment; filename="norma-supporters.csv"','Cache-Control':'private, no-store'}});
     }
     if (p.length === 2 && p[0] === 'consent') return json(await consentDetail(s.org,p[1]));
+    if (p.length === 1 && p[0] === 'reconcile') return json(await reconcileReport(s.org));
     if (p.length === 1) return json(await detail(s.org,p[0]));
     throw new CampaignError(404,'Endpoint not found.');
   } catch(error) { return failure(error); }
@@ -31,6 +33,7 @@ export async function POST(request: NextRequest, context: Context) {
     if (p.length === 2 && p[1] === 'actions') return json({action:await createAction(s,p[0],data)},201);
     if (p.length === 1 && p[0] === 'tasks') return json({task:await saveTask(s,null,data)},201);
     if (p.length === 1 && p[0] === 'consent') return json(await dispatchConfirmation(request,s,data.supporter_id as string));
+    if (p.length === 2 && p[0] === 'donations' && p[1] === 'ingest') return json(await ingestDonation(s,data));
     throw new CampaignError(404,'Endpoint not found.');
   } catch(error) { return failure(error); }
 }
diff --git a/components/campaigns/CampaignWorkspace.tsx b/components/campaigns/CampaignWorkspace.tsx
index ddee9b3..4a5f031 100644
--- a/components/campaigns/CampaignWorkspace.tsx
+++ b/components/campaigns/CampaignWorkspace.tsx
@@ -105,7 +105,7 @@ export default function CampaignWorkspace() {
       </>}
       {!selected && tab==='Supporters' && workspace && <><div className={styles.sectionhead}><div><h2>People, connected.</h2><p className={styles.muted}>A single record across your campaign actions. Latest 500 supporters.</p></div><button className={`${styles.button} ${styles.secondary}`} onClick={()=>void exportCsv()}><Download size={16}/> Export all (up to 10,000)</button></div><div className={styles.controls}><input type="search" aria-label="Search supporters" placeholder="Find by name or email…" value={search} onChange={e=>setSearch(e.target.value)}/></div><div className={styles.note}>An email update request is not a verified subscription. Export includes consent-request status; do not use it as a send-ready mailing list.</div>{supporters.length?<div className={styles.tablewrap}><table className={styles.table}><thead><tr><th>Supporter</th><th>Participation</th><th>Pledged</th><th>Email updates</th><th>Created</th></tr></thead><tbody>{supporters.map(s=><tr key={s.id}><td><strong>{s.full_name}</strong><small>{s.email}</small></td><td>{s.participation_count} actions<small>{s.kinds.map(k=>KIND_LABEL[k]).join(', ')}</small></td><td>{dollars(s.pledged_cents)}</td><td><span className={styles.badge}>{CONSENT_STATE_LABEL[s.consent_state]||'Not requested'}</span>{s.consent_state==='pending'&&<button className={styles.textbutton} disabled={busy} onClick={()=>void dispatchConsent(s.id)}>Send confirmation</button>}</td><td title={s.created_at}>{timestamp(s.created_at)}</td></tr>)}</tbody></table></div>:<Empty title="Your supporter history starts here"><p>Participation connects people across campaigns without creating duplicate records.</p></Empty>}</>}
       {!selected && tab==='Follow-ups' && renderTasks()}
-      {!selected && tab==='Insights' && workspace && <><div className={styles.sectionhead}><h2>See what participation leads to.</h2></div><div className={styles.two}><article className={styles.card}><h3>Organizing outcomes</h3><ul className={styles.list}><li>{workspace.stats.volunteer_signups} volunteer signups</li><li>{workspace.stats.checked_in} event check-ins</li><li>{workspace.stats.monthly_pledges} monthly giving intentions</li><li>{dollars(workspace.stats.pledged_cents)} in pledges · payment collection is separate</li></ul><Link href="/?tab=donations" className={styles.textbutton}>Open the existing donation ledger ↗</Link></article><article className={styles.card}><h3>Where participation starts</h3><p>Share a form with <code>?source=newsletter</code> to attribute responses.</p><ul className={styles.list}>{workspace.sources.map(s=><li key={s.source}><strong>{s.source}</strong> · {s.count} responses · {dollars(s.pledged_cents)} pledged</li>)}</ul>{!workspace.sources.length&&<p>No attribution data yet.</p>}</article></div><div className={styles.sectionhead}><h2>Workspace activity</h2></div><div className={styles.card}>{workspace.audit.length?workspace.audit.map(a=><div className={styles.timeline} key={a.id}><p><strong>{a.event.replaceAll('.',' · ')}</strong> — {a.actor}</p><time className={styles.date} title={a.created_at}>{timestamp(a.created_at)}</time></div>):<p>No activity recorded yet.</p>}</div></>}
+      {!selected && tab==='Insights' && workspace && <><div className={styles.sectionhead}><h2>See what participation leads to.</h2></div><div className={styles.two}><article className={styles.card}><h3>Organizing outcomes</h3><ul className={styles.list}><li>{workspace.stats.volunteer_signups} volunteer signups</li><li>{workspace.stats.checked_in} event check-ins</li><li>{workspace.stats.monthly_pledges} monthly giving intentions</li><li>{dollars(workspace.stats.pledged_cents)} in pledges · payment collection is separate</li></ul><Link href="/?tab=donations" className={styles.textbutton}>Open the existing donation ledger ↗</Link></article><article className={styles.card}><h3>Where participation starts</h3><p>Share a form with <code>?source=newsletter</code> to attribute responses.</p><ul className={styles.list}>{workspace.sources.map(s=><li key={s.source}><strong>{s.source}</strong> · {s.count} responses · {dollars(s.pledged_cents)} pledged</li>)}</ul>{!workspace.sources.length&&<p>No attribution data yet.</p>}</article></div><div className={styles.sectionhead}><h2>Giving reconciliation</h2></div><div className={styles.card}><ul className={styles.list}><li><strong>{dollars(workspace.stats.pledged_cents)}</strong> pledged · <strong>{dollars(workspace.stats.received_cents)}</strong> received · <strong>{dollars(workspace.stats.matched_cents)}</strong> reconciled to a pledge</li><li>{workspace.stats.unattributed_count} donation{workspace.stats.unattributed_count===1?'':'s'} not yet matched to a pledge</li></ul><p className={styles.muted}>One-time contributions are matched to one-time fundraiser pledges by donor email + amount; each pledge reconciles once. Recurring (monthly) reconciliation and the live signature-verified provider webhook are gated milestone-2 work — monthly donations show here as unattributed until then — and no card is ever charged here.</p></div><div className={styles.sectionhead}><h2>Workspace activity</h2></div><div className={styles.card}>{workspace.audit.length?workspace.audit.map(a=><div className={styles.timeline} key={a.id}><p><strong>{a.event.replaceAll('.',' · ')}</strong> — {a.actor}</p><time className={styles.date} title={a.created_at}>{timestamp(a.created_at)}</time></div>):<p>No activity recorded yet.</p>}</div></>}
       {!selected && tab==='Connections' && <><div className={styles.sectionhead}><h2>Your existing tools, in the same workspace.</h2></div><div className={styles.grid}>{[
         ['Petition studio','Write, manage, and deliver petition campaigns.','/?tab=petitions'],['Donation ledger','Review recorded giving separately from campaign pledges.','/?tab=donations'],['Contacts','Work with your existing contact directory and imports.','/?tab=contacts'],['Email workspace','Prepare correspondence through your configured email tools.','/?tab=gmail-crm'],['Social workspace','Prepare and manage your social content.','/?tab=social-overview'],['API connections','Inspect Norma’s existing connected services.','/?tab=integrations'],
       ].filter(([, , url])=>role==='admin'||!['/?tab=social-overview','/?tab=integrations'].includes(url)).map(([title,copy,url])=><a className={styles.card} href={url} key={title}><ArrowUpRight size={18}/><h3>{title}</h3><p>{copy}</p></a>)}</div><div className={styles.note}><strong>Giving and delivery readiness.</strong> Fundraiser forms can link to a hosted ActBlue contribution page. Card collection, recurring billing, refunds, email/SMS sends, and dialing need verified provider integrations. This workspace records pledges and prepares follow-ups; it does not represent those integrations as live.</div></>}
diff --git a/db/029_donation_reconciliation.sql b/db/029_donation_reconciliation.sql
new file mode 100644
index 0000000..a8a617c
--- /dev/null
+++ b/db/029_donation_reconciliation.sql
@@ -0,0 +1,33 @@
+-- Additive donation-reconciliation ledger for the campaign platform (TK-11335, Section A).
+-- Org-scoped and in CENTS (matching organizing_participation pledges) so a recorded
+-- contribution can be reconciled against the pledge that intended it. Deliberately SEPARATE
+-- from the legacy DW `donations` table (which is not org-scoped and carries no donor email),
+-- to preserve the platform's strict tenant isolation. No money moves here: rows are written
+-- only by the sandbox-only mock provider ingest until a verified webhook is wired (milestone 2).
+BEGIN;
+CREATE TABLE IF NOT EXISTS organizing_donations (
+  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+  provider text NOT NULL DEFAULT 'actblue' CHECK (provider IN ('actblue','other')),
+  -- provider's own transaction id; the idempotency key for repeated webhook deliveries.
+  provider_ref text NOT NULL,
+  amount_cents integer NOT NULL CHECK (amount_cents > 0),
+  frequency text NOT NULL DEFAULT 'once' CHECK (frequency IN ('once','monthly')),
+  donor_email text NOT NULL DEFAULT '',
+  donor_name text NOT NULL DEFAULT '',
+  -- the pledge (organizing_participation) this donation fulfills, once reconciled; NULL = unattributed.
+  matched_participation_id uuid REFERENCES organizing_participation(id) ON DELETE SET NULL,
+  match_method text NOT NULL DEFAULT '' CHECK (match_method IN ('','email_amount')),
+  donated_at timestamptz NOT NULL DEFAULT now(),
+  created_at timestamptz NOT NULL DEFAULT now(),
+  -- one row per provider transaction per org: makes webhook redelivery idempotent.
+  UNIQUE (org_id, provider, provider_ref)
+);
+CREATE INDEX IF NOT EXISTS organizing_donations_org ON organizing_donations(org_id, donated_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_donations_match ON organizing_donations(matched_participation_id);
+-- Race-proof one-time reconciliation: a pledge can be fulfilled by at most one donation, enforced at
+-- the DB so two concurrent ingests can never both link (and thus double-count) the same pledge.
+-- Recurring (monthly) reconciliation, which is intentionally one-to-many, is milestone-2 work and is
+-- NOT auto-matched here, so this partial unique index is correct for the one-time matching we do do.
+CREATE UNIQUE INDEX IF NOT EXISTS organizing_donations_one_per_pledge ON organizing_donations(matched_participation_id) WHERE matched_participation_id IS NOT NULL;
+COMMIT;
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
index 30cf0ef..0185184 100644
--- a/docs/platform/RUNBOOK.md
+++ b/docs/platform/RUNBOOK.md
@@ -37,7 +37,8 @@ The gate runs its own isolated process with public campaign intake disabled. Whi
 - `NORMA_CAMPAIGN_SANDBOX=true` works only when `DATABASE_URL` names `sdcc_test`. The preview uses this mode.
 - Public activation/intake defaults off elsewhere. `NORMA_CAMPAIGN_PUBLIC_ENABLED=true` must be deliberately configured for a separately approved public deployment.
 - Set `NORMA_PUBLIC_ORIGIN` to the exact external origin behind an HTTPS reverse proxy. The ingress must overwrite client-IP headers, strip caller-supplied forwarded headers, and impose global abuse limits. The per-action limiter is not a substitute for ingress protection. **`NORMA_PUBLIC_ORIGIN` MUST be pinned before a live email provider is wired** — confirm/unsubscribe links are built from it, and an unpinned (Host-derived) origin would become a phishing link mailed to real supporters. Today this is moot: dispatch is sandbox-only and the link never leaves an authenticated JSON response.
-- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. There is no payment webhook reconciliation or accounting inference.
+- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. No card is ever charged in Norma.
+- Donation reconciliation (migration 029): recorded contributions live in the org-scoped `organizing_donations` ledger (cents), deliberately separate from the legacy non-tenant `donations` table. A ONE-TIME donation is matched to the one-time fundraiser pledge that intended it by donor email + exact amount (FIFO), and a partial unique index guarantees each pledge reconciles at most once (race-proof). The ingest is a SANDBOX-ONLY mock provider push that throws outside the sandbox and always writes to the AUTHENTICATED organizer's org — never a payload org; idempotent on (org, provider, provider_ref) so a redelivered webhook never doubles. RECURRING (monthly) reconciliation is intentionally NOT auto-matched here (it is one-to-many); monthly donations show as unattributed until milestone 2. The PUBLIC, signature-verified webhook transport, real card collection/recurring billing/refunds/receipts, and any production donation write are gated milestone-2 work.
 - A participation email-update checkbox now starts a verified-consent state machine on the supporter (migration 028): `none → pending → confirmed`, with sticky `unsubscribed` and bounce-driven `suppressed` terminal states, every transition appended to an immutable `organizing_consent_events` log (a DB trigger blocks any UPDATE). The organizer "Send confirmation" dispatcher is a sandbox-only MOCK provider — it computes a deterministic delivery result (delivered / bounced → suppressed / transient-failed → retryable) and logs it but NEVER opens a network connection, and it throws outside the sandbox. Real email/SMS transmission through a verified provider is still deferred (see milestone 2). Do not treat a `pending` request as a send-ready subscriber; only `confirmed` reflects a completed double-opt-in.
 - One email has one response per action. Identical retries return the original receipt; a changed duplicate returns a clear conflict instead of silently changing a pledge or consent. Supporter correction/self-service is future work.
 - Existing donation, contact, petition and messaging modules are linked, but their historic records are not automatically merged into campaign supporter history.
diff --git a/lib/campaigns/reconcile.ts b/lib/campaigns/reconcile.ts
new file mode 100644
index 0000000..a27778f
--- /dev/null
+++ b/lib/campaigns/reconcile.ts
@@ -0,0 +1,89 @@
+import type { PoolClient } from 'pg';
+import { query } from '@/lib/db';
+import { audit, transaction, type Scope } from './store';
+import { CampaignError, choice, id, number, text } from './validation';
+import { sandbox } from './validation';
+
+// Deterministic reconciler for ONE-TIME giving: link an unmatched one-time donation to the one-time
+// fundraiser pledge that intended it. Match requires SAME org + SAME donor email (case-insensitive) +
+// EXACT amount, the pledge recorded on or before the donation, and the pledge not already fulfilled.
+// Oldest eligible pledge wins (FIFO). Recurring (monthly) reconciliation is intentionally one-to-many
+// and is milestone-2 work, so a monthly donation is deliberately left unattributed here rather than
+// falsely matched to a single payment. Runs in the caller's transaction with the donation row locked.
+async function matchDonation(client: PoolClient, org: string, donationId: string) {
+  const d = (await client.query('SELECT * FROM organizing_donations WHERE id=$1 AND org_id=$2 FOR UPDATE', [donationId, org])).rows[0];
+  if (!d || d.matched_participation_id || !d.donor_email || d.frequency !== 'once') return d;
+  const pledge = (await client.query(`SELECT p.id FROM organizing_participation p
+    JOIN organizing_supporters s ON s.id=p.supporter_id
+    JOIN organizing_actions a ON a.id=p.action_id
+    WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled' AND p.frequency='once'
+      AND lower(s.email)=lower($2) AND p.amount_cents=$3 AND p.created_at<=$4
+      AND NOT EXISTS (SELECT 1 FROM organizing_donations o WHERE o.matched_participation_id=p.id)
+    ORDER BY p.created_at LIMIT 1`, [org, d.donor_email, d.amount_cents, d.donated_at])).rows[0];
+  if (!pledge) return d;
+  // organizing_donations_one_per_pledge (partial UNIQUE) makes double-linking impossible: if a
+  // concurrent ingest linked this pledge between our SELECT and UPDATE, the UPDATE raises 23505. The
+  // savepoint lets us recover WITHOUT poisoning the outer transaction — we leave this donation
+  // unattributed (honest) rather than double-count reconciled money.
+  await client.query('SAVEPOINT link_pledge');
+  try {
+    return (await client.query("UPDATE organizing_donations SET matched_participation_id=$1, match_method='email_amount' WHERE id=$2 AND org_id=$3 RETURNING *", [pledge.id, donationId, org])).rows[0];
+  } catch (e) {
+    if ((e as { code?: string }).code === '23505') { await client.query('ROLLBACK TO SAVEPOINT link_pledge'); return d; }
+    throw e;
+  }
+}
+
+// SANDBOX-ONLY mock provider ingest. Models an ActBlue-shaped contribution push and reconciles it.
+// Throws outside the sandbox exactly like dispatchConfirmation — the PUBLIC, signature-verified webhook
+// transport (and any production donation write) is a gated milestone. The org is the AUTHENTICATED
+// organizer's org (s.org), never a payload field, so it can never write into another tenant. Idempotent
+// on (org, provider, provider_ref): a redelivered webhook returns the original donation, never a double.
+export async function ingestDonation(s: Scope, payload: Record<string, unknown>) {
+  if (!sandbox()) throw new CampaignError(409, 'A verified payment-provider webhook is not configured for this deployment. Donation ingest is available in the sandbox only.');
+  const provider = choice(payload.provider ?? 'actblue', ['actblue', 'other'] as const, 'provider');
+  const providerRef = text(payload.provider_ref, 'Provider reference', 200);
+  const amount = number(payload.amount_cents, 'Donation amount in cents', 1, 100000000);
+  const frequency = choice(payload.frequency ?? 'once', ['once', 'monthly'] as const, 'frequency');
+  const email = text(payload.donor_email ?? '', 'Donor email', 254, false).toLowerCase();
+  const name = text(payload.donor_name ?? '', 'Donor name', 160, false);
+  // Take the provider's reported transaction time when present (a real webhook carries it); default to
+  // now() when absent. Reject a present-but-invalid timestamp so a malformed webhook can't misdate the
+  // ledger — donated_at also gates matching (a donation can't fulfill a pledge made after it).
+  let donatedAt: string | null = null;
+  const rawDate = payload.donated_at;
+  if (rawDate !== undefined && rawDate !== null && rawDate !== '') {
+    if (typeof rawDate !== 'string' || !Number.isFinite(Date.parse(rawDate))) throw new CampaignError(400, 'donated_at must be a valid date.');
+    donatedAt = new Date(rawDate).toISOString();
+  }
+  return transaction(async client => {
+    const existing = (await client.query('SELECT id FROM organizing_donations WHERE org_id=$1 AND provider=$2 AND provider_ref=$3', [s.org, provider, providerRef])).rows[0];
+    if (existing) { const row = await matchDonation(client, s.org, existing.id); return { donation: row.id, matched: !!row.matched_participation_id, idempotent: true }; }
+    const inserted = (await client.query(`INSERT INTO organizing_donations(org_id,provider,provider_ref,amount_cents,frequency,donor_email,donor_name,donated_at)
+      VALUES($1,$2,$3,$4,$5,$6,$7,COALESCE($8::timestamptz,now())) RETURNING id`, [s.org, provider, providerRef, amount, frequency, email, name, donatedAt])).rows[0];
+    const row = await matchDonation(client, s.org, inserted.id);
+    await audit(client, s, `donation.ingested.${row.matched_participation_id ? 'matched' : 'unmatched'}`, row.id);
+    return { donation: row.id, matched: !!row.matched_participation_id, idempotent: false };
+  });
+}
+
+// Organizer reconciliation report: pledged vs received totals, the fundraiser pledges still awaiting a
+// donation, and the donations not yet attributed to any pledge. Read-only, tenant-scoped.
+export async function reconcileReport(org: string) {
+  id(org);
+  const [totals, unmatchedPledges, unattributed] = await Promise.all([
+    query(`SELECT
+      (SELECT COALESCE(sum(p.amount_cents),0)::float8 FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled') pledged_cents,
+      (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1) received_cents,
+      (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NOT NULL) matched_cents,
+      (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1) donation_count,
+      (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL) unattributed_count`, [org]),
+    query(`SELECT p.id, s.full_name, s.email, p.amount_cents, p.frequency, p.created_at
+      FROM organizing_participation p JOIN organizing_supporters s ON s.id=p.supporter_id JOIN organizing_actions a ON a.id=p.action_id
+      WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled'
+        AND NOT EXISTS (SELECT 1 FROM organizing_donations o WHERE o.matched_participation_id=p.id)
+      ORDER BY p.created_at DESC LIMIT 200`, [org]),
+    query('SELECT id, donor_email, donor_name, amount_cents, frequency, provider, donated_at FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL ORDER BY donated_at DESC LIMIT 200', [org]),
+  ]);
+  return { totals: totals.rows[0], unmatched_pledges: unmatchedPledges.rows, unattributed_donations: unattributed.rows };
+}
diff --git a/lib/campaigns/store.ts b/lib/campaigns/store.ts
index d563a95..bdf77a6 100644
--- a/lib/campaigns/store.ts
+++ b/lib/campaigns/store.ts
@@ -30,7 +30,10 @@ export async function workspace(org: string) {
       COALESCE(sum(p.amount_cents) FILTER(WHERE p.status!='cancelled'),0)::float8 pledged_cents,
       count(*) FILTER(WHERE p.frequency='monthly' AND p.status!='cancelled')::int monthly_pledges,
       count(*) FILTER(WHERE p.status='checked_in')::int checked_in,
-      count(*) FILTER(WHERE a.kind='volunteer' AND p.status!='cancelled')::int volunteer_signups
+      count(*) FILTER(WHERE a.kind='volunteer' AND p.status!='cancelled')::int volunteer_signups,
+      (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1) received_cents,
+      (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NOT NULL) matched_cents,
+      (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL) unattributed_count
       FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE p.org_id=$1`, [org]),
     query(`SELECT source,count(*)::int count,COALESCE(sum(amount_cents),0)::float8 pledged_cents FROM organizing_participation WHERE org_id=$1 AND status!='cancelled' GROUP BY source ORDER BY count(*) DESC LIMIT 100`, [org]),
     query('SELECT * FROM organizing_audit WHERE org_id=$1 ORDER BY created_at DESC LIMIT 40', [org]),
diff --git a/lib/campaigns/types.ts b/lib/campaigns/types.ts
index dc35e1c..73197ae 100644
--- a/lib/campaigns/types.ts
+++ b/lib/campaigns/types.ts
@@ -13,6 +13,6 @@ export interface Supporter { id: string; email: string; full_name: string; creat
 export interface Participation { id: string; action_id: string; action_title: string; kind: ActionKind; full_name: string; email: string; status: 'registered' | 'checked_in' | 'cancelled'; amount_cents: number; frequency: 'once' | 'monthly'; source: string; consent_requested: boolean; created_at: string }
 export interface OrganizingTask { supporter_name?: string; supporter_email?: string; campaign_title?: string; id: string; campaign_id: string; title: string; body: string; channel: TaskChannel; status: 'draft' | 'ready' | 'completed'; assignee?: string; shift_label?: string; due_at?: string | null; created_at: string; updated_at: string }
 export interface CampaignDetail { campaign: Campaign; actions: CampaignAction[]; participation: Participation[]; tasks: OrganizingTask[] }
-export interface Workspace { campaigns: Campaign[]; supporters: Supporter[]; tasks: OrganizingTask[]; stats: { campaigns: number; supporters: number; participation: number; pledged_cents: number; monthly_pledges: number; checked_in: number; volunteer_signups: number }; sources: { source: string; count: number; pledged_cents: number }[]; audit: { id: string; actor: string; event: string; created_at: string }[]; publishing_enabled: boolean; sandbox: boolean }
+export interface Workspace { campaigns: Campaign[]; supporters: Supporter[]; tasks: OrganizingTask[]; stats: { campaigns: number; supporters: number; participation: number; pledged_cents: number; monthly_pledges: number; checked_in: number; volunteer_signups: number; received_cents: number; matched_cents: number; unattributed_count: number }; sources: { source: string; count: number; pledged_cents: number }[]; audit: { id: string; actor: string; event: string; created_at: string }[]; publishing_enabled: boolean; sandbox: boolean }
 export const KIND_LABEL: Record<ActionKind, string> = { petition: 'Petition', fundraiser: 'Fundraiser', event: 'Event', volunteer: 'Volunteer', advocacy: 'Advocacy action' };
 export const CONSENT_TEXT = 'I would like to receive campaign updates by email. My request will need confirmation before I am added to a mailing list.';
diff --git a/scripts/campaign-preview.sh b/scripts/campaign-preview.sh
index b05a535..a4ce8ab 100644
--- a/scripts/campaign-preview.sh
+++ b/scripts/campaign-preview.sh
@@ -8,4 +8,5 @@ export NORMA_CAMPAIGN_SANDBOX=true
 psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/026_campaign_platform.sql
 psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/027_field_assignments.sql
 psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/028_consent.sql
+psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/029_donation_reconciliation.sql
 exec bash scripts/test-instance.sh
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index 3baec19..3304385 100755
--- a/scripts/test-instance.sh
+++ b/scripts/test-instance.sh
@@ -31,7 +31,7 @@ export CRON_SECRET="test-cron-secret"
 # suite back to red with no explanation. (Do NOT blanket-apply db/*.sql: seeds
 # would duplicate fixture rows and alpha-order would run migrations before
 # schema.sql. Add specific idempotent migrations here as the suite grows.)
-for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql db/027_field_assignments.sql db/028_consent.sql; do
+for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql db/027_field_assignments.sql db/028_consent.sql db/029_donation_reconciliation.sql; do
   if psql "$DATABASE_URL" -v ON_ERROR_STOP=0 -f "$m" >/dev/null 2>&1; then
     echo "[test-instance] ensured $m"
   else
@@ -55,6 +55,9 @@ missing=$(psql "$DATABASE_URL" -Atc "
     UNION ALL
     SELECT 'organizing_consent_events (missing migration 028)'
       WHERE to_regclass('public.organizing_consent_events') IS NULL
+    UNION ALL
+    SELECT 'organizing_donations (missing migration 029)'
+      WHERE to_regclass('public.organizing_donations') IS NULL
   ) t" 2>/dev/null || echo "PSQL_UNREACHABLE")
 if [ -n "$missing" ]; then
   echo "[test-instance] FATAL: sdcc_test is missing required schema: $missing"
diff --git a/tests/campaign-disabled.mjs b/tests/campaign-disabled.mjs
index a4f49f5..e9effd7 100644
--- a/tests/campaign-disabled.mjs
+++ b/tests/campaign-disabled.mjs
@@ -16,5 +16,7 @@ const post=await fetch(base+'/api/action-center/'+randomUUID(),{method:'POST',he
 checks.push({name:'Default deployment rejects public intake',verdict:'PASS'});
 const disp=await fetch(base+'/api/campaigns/consent',{method:'POST',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({supporter_id:randomUUID()})});assert.equal(disp.status,409);
 checks.push({name:'Mock consent dispatcher is refused outside the sandbox',verdict:'PASS'});
+const ingest=await fetch(base+'/api/campaigns/donations/ingest',{method:'POST',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({provider:'actblue',provider_ref:randomUUID(),amount_cents:5000,donor_email:'default@example.test'})});assert.equal(ingest.status,409);
+checks.push({name:'Mock donation ingest is refused outside the sandbox',verdict:'PASS'});
 fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/publication-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'No records created or changed; activation and intake rejected.'},null,2));
 console.log('PASS: production-default activation and public intake are disabled');
diff --git a/tests/campaign-platform.mjs b/tests/campaign-platform.mjs
index 59b7e55..43e8b31 100644
--- a/tests/campaign-platform.mjs
+++ b/tests/campaign-platform.mjs
@@ -121,6 +121,44 @@ try {
     await assert.rejects(db.query("UPDATE organizing_consent_events SET detail='tamper' WHERE supporter_id=$1",[ok]));
     assert.equal((await hit('/api/campaigns/consent/'+ok,'GET',undefined,cookie,foreign)).status,404);
   });
+  await check('Donation reconciliation: sandbox ingest matches a pledge, is idempotent, and stays tenant-scoped',async()=>{
+    const donor=run+'-donor@example.test';
+    const pledge=await intake(fundraiser,donor,randomUUID(),{amount_cents:5000,frequency:'once'});assert.equal(pledge.status,202);const pledgeId=pledge.body.receipt;
+    // before any donation the pledge shows as unmatched
+    let rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(rep.unmatched_pledges.some(x=>x.id===pledgeId));
+    // sandbox mock ActBlue-shaped ingest reconciles to the pledge
+    const ref='ab_'+run+'_1';
+    const ing=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref,amount_cents:5000,frequency:'once',donor_email:donor,donor_name:'Test Supporter'});
+    assert.equal(ing.status,200,JSON.stringify(ing.body));assert.equal(ing.body.matched,true);assert.equal(ing.body.idempotent,false);
+    const drow=(await db.query('SELECT matched_participation_id,match_method FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,ref])).rows[0];
+    assert.equal(drow.matched_participation_id,pledgeId);assert.equal(drow.match_method,'email_amount');
+    // idempotent: a redelivered webhook (same provider_ref) returns the original, never a double row
+    const dup=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref,amount_cents:5000,frequency:'once',donor_email:donor});
+    assert.equal(dup.body.idempotent,true);assert.equal(dup.body.donation,ing.body.donation);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,ref])).rows[0].n,1);
+    // pledge now reconciled; totals reflect it
+    rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(!rep.unmatched_pledges.some(x=>x.id===pledgeId));assert.ok(rep.totals.received_cents>=5000);assert.ok(rep.totals.matched_cents>=5000);
+    // a donation with no matching pledge stays unattributed
+    const ref2='ab_'+run+'_2', orphanEmail=run+'-nopledge@example.test';
+    const orphan=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref2,amount_cents:333,frequency:'once',donor_email:orphanEmail});assert.equal(orphan.body.matched,false);
+    rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(rep.unattributed_donations.some(x=>x.donor_email===orphanEmail));
+    // amount mismatch does not match: a $1 donation for the donor leaves a second orphan, pledge already fulfilled
+    const wrong=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_3',amount_cents:100,frequency:'once',donor_email:donor});assert.equal(wrong.body.matched,false);
+    // one-to-one: a SECOND correct one-time donation does not re-link the already-fulfilled pledge (no double-count)
+    const second=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_4',amount_cents:5000,frequency:'once',donor_email:donor});assert.equal(second.body.matched,false);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donations WHERE org_id=$1 AND matched_participation_id=$2',[org,pledgeId])).rows[0].n,1);
+    // recurring is deferred (milestone 2): a monthly donation to a monthly pledge stays unattributed, not falsely matched
+    const mdonor=run+'-monthly@example.test';assert.equal((await intake(fundraiser,mdonor,randomUUID(),{amount_cents:2000,frequency:'monthly'})).status,202);
+    const monthly=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_5',amount_cents:2000,frequency:'monthly',donor_email:mdonor});assert.equal(monthly.body.matched,false);
+    // donated_at: a valid provider timestamp is stored verbatim; a malformed one is rejected 400
+    const when='2026-09-01T12:00:00.000Z';
+    await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_6',amount_cents:777,frequency:'once',donor_email:run+'-dated@example.test',donated_at:when});
+    assert.equal(new Date((await db.query('SELECT donated_at FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,'ab_'+run+'_6'])).rows[0].donated_at).toISOString(),when);
+    assert.equal((await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_7',amount_cents:500,frequency:'once',donor_email:'dated-bad@example.test',donated_at:'not-a-date'})).status,400);
+    // reconcile is org-scoped: even an admin reading the foreign org's report never sees this org's donations
+    const foreignRep=(await hit('/api/campaigns/reconcile',undefined,undefined,cookie,foreign)).body;
+    assert.ok(Array.isArray(foreignRep.unattributed_donations)&&!foreignRep.unattributed_donations.some(x=>x.donor_email===orphanEmail));
+  });
   await check('Follow-up drafts save, transition, and remain unsent',async()=>{
     const r=await hit('/api/campaigns/tasks','POST',{campaign_id:primary,title:'Organizer checklist',body:'Review the campaign actions.',channel:'email',assignee:'Alex Organizer',shift_label:'Saturday park crew',due_at:new Date(Date.now()+86400000).toISOString()});assert.equal(r.status,201);const task=r.body.task.id;assert.equal(r.body.task.assignee,'Alex Organizer');assert.equal(r.body.task.shift_label,'Saturday park crew');
     assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{status:'ready',body:'Updated draft'})).body.task.status,'ready');
@@ -141,6 +179,6 @@ try {
   console.log(`${checks.length} integration journeys passed`);
 } catch(error) {checks.push({name:'Execution failure',verdict:'FAIL',reason:error.message});console.error(error);process.exitCode=1;}
 finally {
-  if(campaigns.length){await db.query('DELETE FROM organizing_tasks WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_participation WHERE action_id=ANY($1::uuid[])',[actions]);await db.query("DELETE FROM organizing_consent_events WHERE org_id=$2 AND supporter_id IN (SELECT id FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2)",[run+'%',org]);await db.query("DELETE FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2",[run+'%',org]);await db.query('DELETE FROM organizing_actions WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_audit WHERE entity_id=ANY($1::uuid[])',[campaigns.concat(actions)]);await db.query('DELETE FROM organizing_campaigns WHERE id=ANY($1::uuid[])',[campaigns]);}
+  if(campaigns.length){await db.query("DELETE FROM organizing_donations WHERE org_id=$1 AND provider_ref LIKE $2",[org,'ab_'+run+'%']);await db.query('DELETE FROM organizing_tasks WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_participation WHERE action_id=ANY($1::uuid[])',[actions]);await db.query("DELETE FROM organizing_consent_events WHERE org_id=$2 AND supporter_id IN (SELECT id FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2)",[run+'%',org]);await db.query("DELETE FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2",[run+'%',org]);await db.query('DELETE FROM organizing_actions WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_audit WHERE entity_id=ANY($1::uuid[])',[campaigns.concat(actions)]);await db.query('DELETE FROM organizing_campaigns WHERE id=ANY($1::uuid[])',[campaigns]);}
   await db.end();fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/api-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'Owned fixture campaign, action, supporter and participation records removed; audit receipt rows retained as verification history.'},null,2));
 }
diff --git a/verification/platform/api-results.json b/verification/platform/api-results.json
index b9b3691..6e068ac 100644
--- a/verification/platform/api-results.json
+++ b/verification/platform/api-results.json
@@ -1,5 +1,5 @@
 {
-  "timestamp": "2026-09-09T22:16:57.572Z",
+  "timestamp": "2026-09-09T22:58:19.575Z",
   "base": "http://127.0.0.1:7416",
   "checks": [
     {
@@ -46,6 +46,10 @@
       "name": "Verified-consent state machine, mock dispatch, sticky opt-out, and immutable log",
       "verdict": "PASS"
     },
+    {
+      "name": "Donation reconciliation: sandbox ingest matches a pledge, is idempotent, and stays tenant-scoped",
+      "verdict": "PASS"
+    },
     {
       "name": "Follow-up drafts save, transition, and remain unsent",
       "verdict": "PASS"
diff --git a/verification/platform/browser-results.json b/verification/platform/browser-results.json
index 34374c8..751f4e6 100644
--- a/verification/platform/browser-results.json
+++ b/verification/platform/browser-results.json
@@ -104,12 +104,12 @@
                   "workerIndex": 0,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 4113,
+                  "duration": 2387,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:18.212Z",
+                  "startTime": "2026-09-09T22:58:31.894Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -144,12 +144,12 @@
                   "workerIndex": 0,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 1298,
+                  "duration": 1246,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:22.558Z",
+                  "startTime": "2026-09-09T22:58:34.525Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -184,12 +184,12 @@
                   "workerIndex": 0,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 4158,
+                  "duration": 18972,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:23.860Z",
+                  "startTime": "2026-09-09T22:58:35.775Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -224,12 +224,12 @@
                   "workerIndex": 0,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 1933,
+                  "duration": 8935,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:28.022Z",
+                  "startTime": "2026-09-09T22:58:54.751Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -264,12 +264,12 @@
                   "workerIndex": 1,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 3237,
+                  "duration": 5504,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:30.195Z",
+                  "startTime": "2026-09-09T22:59:04.090Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -304,12 +304,12 @@
                   "workerIndex": 1,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 3879,
+                  "duration": 1706,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:33.922Z",
+                  "startTime": "2026-09-09T22:59:10.043Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -344,12 +344,12 @@
                   "workerIndex": 1,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 4529,
+                  "duration": 13529,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:37.806Z",
+                  "startTime": "2026-09-09T22:59:11.753Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -384,12 +384,12 @@
                   "workerIndex": 1,
                   "parallelIndex": 0,
                   "status": "passed",
-                  "duration": 2248,
+                  "duration": 2328,
                   "errors": [],
                   "stdout": [],
                   "stderr": [],
                   "retry": 0,
-                  "startTime": "2026-09-09T22:17:42.339Z",
+                  "startTime": "2026-09-09T22:59:25.288Z",
                   "annotations": [],
                   "attachments": [
                     {
@@ -413,8 +413,8 @@
   ],
   "errors": [],
   "stats": {
-    "startTime": "2026-09-09T22:17:18.004Z",
-    "duration": 26634.139000000003,
+    "startTime": "2026-09-09T22:58:31.618Z",
+    "duration": 56044.723999999995,
     "expected": 8,
     "skipped": 0,
     "unexpected": 0,
diff --git a/verification/platform/publication-results.json b/verification/platform/publication-results.json
index 051e696..daf040c 100644
--- a/verification/platform/publication-results.json
+++ b/verification/platform/publication-results.json
@@ -1,5 +1,5 @@
 {
-  "timestamp": "2026-09-09T22:18:33.007Z",
+  "timestamp": "2026-09-09T22:59:29.754Z",
   "base": "http://127.0.0.1:7417",
   "checks": [
     {
@@ -13,6 +13,10 @@
     {
       "name": "Mock consent dispatcher is refused outside the sandbox",
       "verdict": "PASS"
+    },
+    {
+      "name": "Mock donation ingest is refused outside the sandbox",
+      "verdict": "PASS"
     }
   ],
   "cleanup": "No records created or changed; activation and intake rejected."
diff --git a/verification/platform/screenshots/chromium-advocacy.png b/verification/platform/screenshots/chromium-advocacy.png
index 8832c80..531991f 100644
Binary files a/verification/platform/screenshots/chromium-advocacy.png and b/verification/platform/screenshots/chromium-advocacy.png differ
diff --git a/verification/platform/screenshots/chromium-campaign.png b/verification/platform/screenshots/chromium-campaign.png
index 4224c4b..c9621c9 100644
Binary files a/verification/platform/screenshots/chromium-campaign.png and b/verification/platform/screenshots/chromium-campaign.png differ
diff --git a/verification/platform/screenshots/chromium-consent.png b/verification/platform/screenshots/chromium-consent.png
index ef7fb31..178e722 100644
Binary files a/verification/platform/screenshots/chromium-consent.png and b/verification/platform/screenshots/chromium-consent.png differ
diff --git a/verification/platform/screenshots/chromium-event.png b/verification/platform/screenshots/chromium-event.png
index a3e70eb..4a9d619 100644
Binary files a/verification/platform/screenshots/chromium-event.png and b/verification/platform/screenshots/chromium-event.png differ
diff --git a/verification/platform/screenshots/chromium-fundraiser.png b/verification/platform/screenshots/chromium-fundraiser.png
index 5b278ee..cbbd82f 100644
Binary files a/verification/platform/screenshots/chromium-fundraiser.png and b/verification/platform/screenshots/chromium-fundraiser.png differ
diff --git a/verification/platform/screenshots/chromium-mobile.png b/verification/platform/screenshots/chromium-mobile.png
index f5743f5..4ccad56 100644
Binary files a/verification/platform/screenshots/chromium-mobile.png and b/verification/platform/screenshots/chromium-mobile.png differ
diff --git a/verification/platform/screenshots/chromium-receipt.png b/verification/platform/screenshots/chromium-receipt.png
index fd7e67b..5840c71 100644
Binary files a/verification/platform/screenshots/chromium-receipt.png and b/verification/platform/screenshots/chromium-receipt.png differ
diff --git a/verification/platform/screenshots/chromium-volunteer.png b/verification/platform/screenshots/chromium-volunteer.png
index ad43204..b434e15 100644
Binary files a/verification/platform/screenshots/chromium-volunteer.png and b/verification/platform/screenshots/chromium-volunteer.png differ
diff --git a/verification/platform/screenshots/webkit-advocacy.png b/verification/platform/screenshots/webkit-advocacy.png
index bbc393b..c9ed419 100644
Binary files a/verification/platform/screenshots/webkit-advocacy.png and b/verification/platform/screenshots/webkit-advocacy.png differ
diff --git a/verification/platform/screenshots/webkit-campaign.png b/verification/platform/screenshots/webkit-campaign.png
index cedc6a8..289c167 100644
Binary files a/verification/platform/screenshots/webkit-campaign.png and b/verification/platform/screenshots/webkit-campaign.png differ
diff --git a/verification/platform/screenshots/webkit-consent.png b/verification/platform/screenshots/webkit-consent.png
index e12f0b8..381d243 100644
Binary files a/verification/platform/screenshots/webkit-consent.png and b/verification/platform/screenshots/webkit-consent.png differ
diff --git a/verification/platform/screenshots/webkit-event.png b/verification/platform/screenshots/webkit-event.png
index 9c676df..1b62756 100644
Binary files a/verification/platform/screenshots/webkit-event.png and b/verification/platform/screenshots/webkit-event.png differ
diff --git a/verification/platform/screenshots/webkit-fundraiser.png b/verification/platform/screenshots/webkit-fundraiser.png
index d3680d9..b51c022 100644
Binary files a/verification/platform/screenshots/webkit-fundraiser.png and b/verification/platform/screenshots/webkit-fundraiser.png differ
diff --git a/verification/platform/screenshots/webkit-mobile.png b/verification/platform/screenshots/webkit-mobile.png
index ac3838b..4fe9976 100644
Binary files a/verification/platform/screenshots/webkit-mobile.png and b/verification/platform/screenshots/webkit-mobile.png differ
diff --git a/verification/platform/screenshots/webkit-receipt.png b/verification/platform/screenshots/webkit-receipt.png
index 9f3c4e7..e7dd0c0 100644
Binary files a/verification/platform/screenshots/webkit-receipt.png and b/verification/platform/screenshots/webkit-receipt.png differ
diff --git a/verification/platform/screenshots/webkit-volunteer.png b/verification/platform/screenshots/webkit-volunteer.png
index c8b9e03..990cdf3 100644
Binary files a/verification/platform/screenshots/webkit-volunteer.png and b/verification/platform/screenshots/webkit-volunteer.png differ

← 6d61307 Apply consent red-team fixes: fail-closed token secret + per  ·  back to Norma Platform  ·  chore: lint, refactor, v0.2.0 (session close) 2c3739d →