From b14d2fd0c7c6cda42c72a61d3b104f0beeefc3b3 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Tue, 11 Aug 2026 15:10:58 +0000
Subject: [PATCH 1/3] feat(ai): the outreach composer, and the checks that make
it trustworthy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `packages/ai` — the only package in the tree that talks to a model. The
composer writes the message body for a recommendation, and everything around
it exists to make that output safe to show a reviewer.
Two properties are structural rather than requested:
1. It can only see grounded inputs. The prompt is assembled from stored
evidence, stored facts and the customer's own offering. There is no path
by which the model learns something it may not cite.
2. Its output is checked, not trusted. Eight deterministic §14.2 gates run
on every draft — grounding overlap, unsupported claims, identity
confidence, flattery, spam patterns, cross-prospect duplication,
sensitive topics, policy. A draft that fails is rewritten once, naming
the exact invented fragments; still failing, it is withheld.
Withheld is a working state, not an error. The card keeps the prospect, the
evidence and the recommended action, and the reviewer writes the message. A
bad draft next to a caveat is still a bad draft someone might approve.
The undecidable question "is this claim true?" is inverted into a decidable
one: does every specific assertion — quoted phrase, number, mid-sentence
proper noun — appear in stored evidence? That is what `checks.ts` answers,
and it is why no model output reaches a human unexamined.
Also:
- `POST /recommendations/:id/draft` composes or recomposes on demand, returns
200 with `drafted: false` and a reason when withholding, and audits it.
A user-edited draft is never discarded by a recompose.
- The approval card gains "Write draft" / "Rewrite", and states plainly why
no draft exists rather than offering a retry that cannot help.
- Prompt caching splits the stable offering/voice prefix from the
per-prospect half, so a campaign pays for the prefix once.
- The composer is optional infrastructure: without ANTHROPIC_API_KEY the
queue still runs end to end and drafting returns 503. Refusing to boot
would take the system down for a feature designed to produce nothing.
No model decides a merge, a score, or a policy outcome. 367 tests pass.
Co-Authored-By: Claude Opus 5 (1M context)
---
.env.example | 6 +
README.md | 18 +-
apps/api/package.json | 3 +-
apps/api/src/app.test.ts | 101 ++++++
apps/api/src/app.ts | 66 ++++
apps/server/package.json | 3 +-
apps/server/src/index.ts | 19 ++
apps/web/components/approval-card.tsx | 128 +++++--
apps/worker/package.json | 3 +-
apps/worker/src/pipeline.ts | 19 ++
bun.lock | 22 ++
docs/prd-implementation-map.md | 7 +-
packages/ai/package.json | 18 +
packages/ai/src/checks.test.ts | 307 +++++++++++++++++
packages/ai/src/checks.ts | 465 ++++++++++++++++++++++++++
packages/ai/src/composer.test.ts | 292 ++++++++++++++++
packages/ai/src/composer.ts | 297 ++++++++++++++++
packages/ai/src/draft.test.ts | 184 ++++++++++
packages/ai/src/draft.ts | 239 +++++++++++++
packages/ai/src/index.ts | 41 +++
packages/ai/src/model.ts | 143 ++++++++
tsconfig.json | 4 +-
22 files changed, 2353 insertions(+), 32 deletions(-)
create mode 100644 packages/ai/package.json
create mode 100644 packages/ai/src/checks.test.ts
create mode 100644 packages/ai/src/checks.ts
create mode 100644 packages/ai/src/composer.test.ts
create mode 100644 packages/ai/src/composer.ts
create mode 100644 packages/ai/src/draft.test.ts
create mode 100644 packages/ai/src/draft.ts
create mode 100644 packages/ai/src/index.ts
create mode 100644 packages/ai/src/model.ts
diff --git a/.env.example b/.env.example
index d89deb7..cb06652 100644
--- a/.env.example
+++ b/.env.example
@@ -39,7 +39,13 @@ X_API_SECRET=
# ---------------------------------------------------------------------- LLM
# Never exposed to the browser, and never included in a model prompt alongside
# customer OAuth tokens (PRD §34).
+#
+# Optional. Unset, the composer is disabled: signals, resolution, scoring and
+# the approval queue all still run, and the reviewer writes the message. It is
+# never consulted for a policy, identity or scoring decision (PRD §1.1.8).
ANTHROPIC_API_KEY=
+# Overrides the default model (claude-opus-5).
+ANTHROPIC_MODEL=
# -------------------------------------------------------------------- queue
# Optional until queued jobs need durability.
diff --git a/README.md b/README.md
index abb2504..10e5fb7 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,10 @@ platform, the prospect, or your own rate limits say it should not.
## Status
-Foundation. The deterministic core, the API, the PWA and one live provider are
-built and tested; a prospect can go from a GitHub handle to a card in the
-approval queue today. The LLM layer — the outreach composer and the rest of the
-§20 agent suite — is not built. See
+Foundation. The deterministic core, the API, the PWA, one live provider and the
+outreach composer are built and tested; a prospect can go from a GitHub handle
+to a drafted, checked card in the approval queue today. The rest of the §20
+agent suite is not built. See
[`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) for exactly
what exists.
@@ -43,6 +43,7 @@ apps/
web/ Next.js 16 mobile-first PWA
worker/ background jobs: signal expiry, rescoring, privacy work
packages/
+ ai/ the only package that talks to a model: composer + quality gates
domain/ canonical types — depends on nothing
db/ Turso/libSQL client and migration runner
policy/ the deterministic policy engine
@@ -88,6 +89,15 @@ state. Suppression, a flipped feature flag, a spent rate limit, or a dropped
identity confidence all block an approval that would have been fine yesterday,
and the response names the gate that stopped it.
+**A draft that fails its checks is withheld, not shown with a warning.** The
+composer only ever sees stored evidence, stored facts and your own offering —
+there is no path by which it learns something it may not cite. Its output then
+runs deterministic gates: every specific assertion must appear in that
+evidence, or the draft is rejected and rewritten once naming the exact invented
+fragments. Still failing, nothing is shown. The card keeps the prospect, the
+evidence and the recommended action, and you write the message. A bad draft
+next to a caveat is still a bad draft someone might approve.
+
**Evidence combines with noisy-OR, so weak signals never reach certainty.**
Identity resolution and intent scoring both use `1 - Π(1 - eᵢ)`. Two 0.5
observations give 0.75, not 1.0. "Same name, same city" — the classic
diff --git a/apps/api/package.json b/apps/api/package.json
index 340eb8c..61e4dff 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
- "zod": "^3.24.1"
+ "zod": "^3.24.1",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts
index 6090eab..6f0c21d 100644
--- a/apps/api/src/app.test.ts
+++ b/apps/api/src/app.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
+import { StubModel } from '@outreachgraph/ai';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
@@ -484,3 +485,103 @@ describe('errors', () => {
expect(response.status).toBe(400);
});
});
+
+describe('drafting on demand (PRD §14)', () => {
+ /** The seed's evidence is a cross-border payouts complaint. */
+ const GROUNDED = 'Settlement taking days on cross-border payouts was our problem too.';
+
+ async function withModel(
+ label: string,
+ responses: string | readonly string[],
+ ): Promise> {
+ const seeded = await seedDatabase(label);
+ active = seeded;
+
+ return createApp({
+ db: seeded.db,
+ authenticate: async () => ACTOR,
+ model: new StubModel(responses),
+ });
+ }
+
+ test('returns 503 when no model is configured', async () => {
+ const { app } = await harness('draft-nomodel');
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ expect(response.status).toBe(503);
+ expect((await response.json()).error.code).toBe('composer_unavailable');
+ });
+
+ test('composes a grounded message and returns it', async () => {
+ const app = await withModel('draft-ok', GROUNDED);
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.drafted).toBe(true);
+ expect(body.body).toBe(GROUNDED);
+ });
+
+ test('replaces the previous draft rather than stacking a second one', async () => {
+ const app = await withModel('draft-replace', GROUNDED);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute({
+ sql: 'SELECT count(*) AS n FROM drafts WHERE recommendation_id = ?',
+ args: [SEED.recommendationId],
+ });
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('reports a withheld draft instead of surfacing an invented one', async () => {
+ const app = await withModel('draft-withheld', [
+ 'On cross-border payouts, Fluxwire solved this for us.',
+ 'Your payouts note — we moved to Fluxwire.',
+ ]);
+
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ // Not an error: refusing to write is a normal, expected answer.
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.drafted).toBe(false);
+ expect(body.reason).toBe('failed_checks');
+ expect(body.unsupported).toContain('Fluxwire');
+ expect(body.body).toBeUndefined();
+ });
+
+ test('a withheld draft is audited', async () => {
+ const app = await withModel('draft-audit', [
+ 'On cross-border payouts, Fluxwire solved this for us.',
+ 'Your payouts note — we moved to Fluxwire.',
+ ]);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute(
+ "SELECT count(*) AS n FROM audit_events WHERE event_type = 'draft.withheld'",
+ );
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('an unknown recommendation is a 404', async () => {
+ const app = await withModel('draft-404', GROUNDED);
+ expect((await post(app, '/recommendations/rec_missing/draft')).status).toBe(404);
+ });
+
+ test('a draft the user edited is never discarded by a recompose', async () => {
+ const app = await withModel('draft-edited', GROUNDED);
+ await active!.db.execute({
+ sql: 'UPDATE drafts SET edited_by_user = 1 WHERE id = ?',
+ args: [SEED.draftId],
+ });
+
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute({
+ sql: 'SELECT edited_by_user FROM drafts WHERE id = ?',
+ args: [SEED.draftId],
+ });
+ expect(rows.rows).toHaveLength(1);
+ });
+});
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index c2e06f0..dff5e6e 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -33,6 +33,7 @@ import {
workspacesForUser,
} from './auth';
import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy';
+import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { ApiError, canApprove, type AppEnv, type RequestActor } from './context';
import * as repo from './repository';
@@ -50,6 +51,11 @@ export interface AppOptions {
readonly serviceToken?: string;
/** Set false for plain-HTTP local development so the cookie still sets. */
readonly secureCookies?: boolean;
+ /**
+ * Writes outreach drafts. Omit to run without a language model — every
+ * other route works unchanged and drafting returns 503.
+ */
+ readonly model?: TextModel;
readonly version?: string;
readonly commitHash?: string;
}
@@ -451,6 +457,66 @@ export function createApp(options: AppOptions): Hono {
});
});
+ /**
+ * Compose (or recompose) the message for a recommendation.
+ *
+ * Separate from generation because a draft is optional: the pipeline places
+ * the card in the queue whether or not the composer produced anything, and
+ * the reviewer can ask for one here. A refusal to write is a normal answer,
+ * returned with the reason and the specific fragments that failed grounding
+ * — the alternative is showing an invented message, which is worse.
+ */
+ api.post('/recommendations/:id/draft', async (c) => {
+ const actor = c.get('actor');
+ const db = c.get('db');
+
+ if (!options.model) {
+ throw new ApiError(
+ 503,
+ 'composer_unavailable',
+ 'no language model is configured; set ANTHROPIC_API_KEY to enable drafting',
+ );
+ }
+
+ const recommendation = await repo.getRecommendation(db, actor.workspaceId, c.req.param('id'));
+ if (!recommendation) throw ApiError.notFound('recommendation');
+
+ // Recomposing replaces the previous draft; the writer asked for a rewrite.
+ await db.execute({
+ sql: 'DELETE FROM drafts WHERE recommendation_id = ? AND edited_by_user = 0',
+ args: [recommendation.id],
+ });
+
+ const result = await draftForRecommendation(db, options.model, recommendation.id);
+
+ if (!result.ok) {
+ await repo.audit(db, {
+ workspaceId: actor.workspaceId,
+ actorKind: 'user',
+ actorId: actor.userId,
+ eventType: 'draft.withheld',
+ entityKind: 'recommendation',
+ entityId: recommendation.id,
+ detail: { reason: result.reason, unsupported: result.unsupported ?? [] },
+ });
+
+ return c.json(
+ {
+ drafted: false,
+ reason: result.reason,
+ ...(result.unsupported ? { unsupported: result.unsupported } : {}),
+ },
+ 200,
+ );
+ }
+
+ const draft = await queryOne<{ body: string }>(db, 'SELECT body FROM drafts WHERE id = ?', [
+ result.draftId!,
+ ]);
+
+ return c.json({ drafted: true, draftId: result.draftId, body: draft?.body });
+ });
+
api.post('/recommendations/:id/skip', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
diff --git a/apps/server/package.json b/apps/server/package.json
index d2ba3fd..48d15bc 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
- "zod": "^3.24.1"
+ "zod": "^3.24.1",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 69687d3..8dc38ec 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -20,6 +20,7 @@
*/
import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db';
+import { ClaudeModel } from '@outreachgraph/ai';
import { createApp } from '../../api/src/app';
import { pruneSessions } from '../../api/src/auth';
import { expireSignals, processDeletion } from '../../worker/src/jobs';
@@ -60,9 +61,27 @@ if (process.env.RUN_MIGRATIONS !== 'false') {
}
}
+// -------------------------------------------------------------------- model
+/**
+ * The composer is optional infrastructure.
+ *
+ * Without a key the product still works: signals are ingested, prospects
+ * resolved, recommendations scored and queued — the reviewer writes the
+ * message. Refusing to boot over a missing key would take the whole system
+ * down for a feature that is, by design, allowed to produce nothing.
+ */
+const model = process.env.ANTHROPIC_API_KEY
+ ? new ClaudeModel({
+ ...(process.env.ANTHROPIC_MODEL ? { model: process.env.ANTHROPIC_MODEL } : {}),
+ })
+ : undefined;
+
+if (!model) console.log('no ANTHROPIC_API_KEY: drafting disabled, queue still runs');
+
// ---------------------------------------------------------------------- api
const api = createApp({
db,
+ ...(model ? { model } : {}),
...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}),
// Cookies must not be Secure over plain HTTP, or local development can
// never hold a session.
diff --git a/apps/web/components/approval-card.tsx b/apps/web/components/approval-card.tsx
index 61f0006..bdf6190 100644
--- a/apps/web/components/approval-card.tsx
+++ b/apps/web/components/approval-card.tsx
@@ -18,13 +18,84 @@ import type { ApprovalCard as Card } from '../lib/types';
* policy gate that stopped it, and that reason is shown verbatim. Silently
* dropping it would leave the user thinking they had sent something.
*/
+/**
+ * Why no draft was written, in the reviewer's language.
+ *
+ * The reason is shown rather than hidden behind "try again", because every
+ * one of these is a fact about the evidence: retrying will not change it, and
+ * the honest next step is for the reviewer to write the message themselves.
+ */
+function explainWithholding(reason: string, unsupported?: string[]): string {
+ switch (reason) {
+ case 'no_evidence':
+ return 'No draft: nothing quotable was captured for this signal, and a personalised message with nothing behind it is worse than none.';
+ case 'no_trigger_signal':
+ return 'No draft: this recommendation has no signal to reference.';
+ case 'failed_checks':
+ return unsupported?.length
+ ? `No draft: the wording kept asserting things nothing supports — ${unsupported.join(', ')}.`
+ : 'No draft: the wording did not pass the quality checks.';
+ case 'model_refused':
+ case 'empty':
+ return 'No draft: the writer declined to produce one.';
+ default:
+ return `No draft (${reason}).`;
+ }
+}
+
export function ApprovalCard({ card }: { card: Card }) {
const router = useRouter();
const [busy, setBusy] = useState();
const [error, setError] = useState();
const [body, setBody] = useState(card.draft_body ?? '');
+ // What the composer produced, so an approval can tell an edit from the
+ // original rather than reporting every composed message as user-written.
+ const [original, setOriginal] = useState(card.draft_body ?? '');
+ const [withheld, setWithheld] = useState();
const [editing, setEditing] = useState(false);
+ /**
+ * Ask the composer for wording.
+ *
+ * A withheld draft comes back 200 with a reason — it is the designed
+ * outcome when nothing in the evidence supports a message, not an error.
+ * It is shown as a note so the reviewer knows to write it themselves.
+ */
+ async function compose() {
+ setBusy('draft');
+ setError(undefined);
+ setWithheld(undefined);
+
+ try {
+ const response = await fetch(`/api/v1/recommendations/${card.id}/draft`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ credentials: 'same-origin',
+ body: '{}',
+ });
+
+ const payload = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ setError(payload?.error?.message ?? `that failed (${response.status})`);
+ return;
+ }
+
+ if (!payload.drafted) {
+ setWithheld(explainWithholding(payload.reason, payload.unsupported));
+ return;
+ }
+
+ setBody(payload.body);
+ setOriginal(payload.body);
+ router.refresh();
+ } catch {
+ setError('could not reach the server');
+ } finally {
+ setBusy(undefined);
+ }
+ }
+
async function act(action: 'approve' | 'skip', payload?: Record) {
setBusy(action);
setError(undefined);
@@ -108,25 +179,44 @@ export function ApprovalCard({ card }: { card: Card }) {
- {card.draft_body ? (
-
+
{error ? (
@@ -138,9 +228,7 @@ export function ApprovalCard({ card }: { card: Card }) {
- act('approve', editing && body !== card.draft_body ? { editedBody: body } : {})
- }
+ onClick={() => act('approve', body && body !== original ? { editedBody: body } : {})}
className="bg-accent rounded-xl py-2 text-sm font-medium text-white disabled:opacity-60"
>
{busy === 'approve' ? 'Approving…' : 'Approve'}
@@ -148,7 +236,7 @@ export function ApprovalCard({ card }: { card: Card }) {
setEditing((v) => !v)}
className="border-border rounded-xl border py-2 text-sm font-medium disabled:opacity-40"
>
diff --git a/apps/worker/package.json b/apps/worker/package.json
index d6c4275..74e7da2 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -18,6 +18,7 @@
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
- "@outreachgraph/recommend": "workspace:*"
+ "@outreachgraph/recommend": "workspace:*",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/worker/src/pipeline.ts b/apps/worker/src/pipeline.ts
index 4c0f2ec..3f8c9bc 100644
--- a/apps/worker/src/pipeline.ts
+++ b/apps/worker/src/pipeline.ts
@@ -23,6 +23,7 @@ import {
type PersonEnrichmentProvider,
} from '@outreachgraph/providers';
import { generateRecommendation, type CandidateSignal } from '@outreachgraph/recommend';
+import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { rescoreProspect } from './jobs';
export interface PipelineOptions {
@@ -32,6 +33,11 @@ export interface PipelineOptions {
readonly providers: readonly PersonEnrichmentProvider[];
/** Supplies GitHub activity. Omit to skip signal collection. */
readonly github?: GitHubProvider;
+ /**
+ * Writes the message. Omit to run the pipeline with no LLM at all — the
+ * recommendation still reaches the queue, just without a draft.
+ */
+ readonly model?: TextModel;
readonly now?: Date;
}
@@ -41,6 +47,7 @@ export interface PipelineResult {
readonly identitiesLinked: number;
readonly signalsStored: number;
readonly recommendationId?: string;
+ readonly draftId?: string;
readonly stoppedBecause?: string;
}
@@ -124,6 +131,17 @@ export async function runPipeline(
};
}
+ // ---------------------------------------------------------------- draft
+ // A failed or absent draft is not a pipeline failure. The card still shows
+ // the prospect, the evidence and the recommended action; the reviewer
+ // writes the message. That beats showing a fabricated one.
+ let draftId: string | undefined;
+ if (options.model) {
+ const draft = await draftForRecommendation(db, options.model, recommendationId);
+ if (draft.ok) draftId = draft.draftId;
+ else console.log(`no draft for ${recommendationId}: ${draft.reason}`);
+ }
+
await setStatus(db, campaignId, personId, 'awaiting_approval');
return {
@@ -132,6 +150,7 @@ export async function runPipeline(
identitiesLinked: linked,
signalsStored: stored,
recommendationId,
+ ...(draftId ? { draftId } : {}),
};
}
diff --git a/bun.lock b/bun.lock
index 3bfd730..2a4131b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,7 @@
"name": "@outreachgraph/api",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
@@ -29,6 +30,7 @@
"name": "@outreachgraph/server",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
@@ -63,6 +65,7 @@
"name": "@outreachgraph/worker",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
"@outreachgraph/identity": "workspace:*",
@@ -73,6 +76,15 @@
"@outreachgraph/signals": "workspace:*",
},
},
+ "packages/ai": {
+ "name": "@outreachgraph/ai",
+ "version": "0.1.0",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.70.0",
+ "@outreachgraph/db": "workspace:*",
+ "@outreachgraph/domain": "workspace:*",
+ },
+ },
"packages/contracts": {
"name": "@outreachgraph/contracts",
"version": "0.1.0",
@@ -147,6 +159,10 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.70.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A=="],
+
+ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
+
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -261,6 +277,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA=="],
+ "@outreachgraph/ai": ["@outreachgraph/ai@workspace:packages/ai"],
+
"@outreachgraph/api": ["@outreachgraph/api@workspace:apps/api"],
"@outreachgraph/contracts": ["@outreachgraph/contracts@workspace:packages/contracts"],
@@ -357,6 +375,8 @@
"js-base64": ["js-base64@3.9.2", "", {}, "sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA=="],
+ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
+
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -419,6 +439,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md
index 6fafd03..8923cf8 100644
--- a/docs/prd-implementation-map.md
+++ b/docs/prd-implementation-map.md
@@ -22,7 +22,8 @@ Where each part of the V1 PRD lives. Code comments cite section numbers
| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests |
| §13.1 Action kinds | `packages/domain/src/networks.ts` | — |
| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — |
-| §14.2 Quality checks (types) | `packages/domain/src/outreach.ts` | — |
+| §14.1 Outreach composer | `packages/ai/src/composer.ts`, `model.ts`, `draft.ts` | 29 tests |
+| §14.2 Quality checks | `packages/ai/src/checks.ts`, `packages/domain/src/outreach.ts` | 40 tests |
| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` |
| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests |
| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above |
@@ -57,13 +58,11 @@ Where each part of the V1 PRD lives. Code comments cite section numbers
## Not started
- §7 Campaign wizard UI.
-- §14 Outreach composer. Recommendations reach the queue with a grounded reason and a `groundedSignalIds` allow-list, but nothing writes the message body yet — this is where the first LLM enters the product.
-- §20 agent suite beyond the Strategy Agent (ICP, discovery, research, intent, composer).
+- §20 agent suite beyond the Strategy Agent and the composer (ICP, discovery, research, intent).
- §10 Remaining provider adapters — Apollo, PDL, Bluesky, X.
- §26 Natural-language search.
- §28 CRM and Slack integrations.
- §36 Admin surface.
-- Authentication beyond the injected `authenticate` hook.
## Deliberate deviations
diff --git a/packages/ai/package.json b/packages/ai/package.json
new file mode 100644
index 0000000..836a305
--- /dev/null
+++ b/packages/ai/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@outreachgraph/ai",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.70.0",
+ "@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/db": "workspace:*"
+ }
+}
diff --git a/packages/ai/src/checks.test.ts b/packages/ai/src/checks.test.ts
new file mode 100644
index 0000000..1023ff0
--- /dev/null
+++ b/packages/ai/src/checks.test.ts
@@ -0,0 +1,307 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ extractClaims,
+ failedChecks,
+ findUnsupportedClaims,
+ runChecks,
+ similarityFingerprint,
+ type CheckInput,
+ type GroundingContext,
+} from './checks';
+
+const GROUNDING: GroundingContext = {
+ evidence: [
+ 'Does anyone have a good alternative to Stripe for cross-border payouts? ' +
+ 'Fees are brutal and settlement takes days.',
+ ],
+ facts: ['Jane Smith', 'Jane', 'VP Engineering', 'Acme', 'x'],
+ offering: [
+ 'ExamplePay',
+ 'developer payments infrastructure',
+ 'reduce payment integration time',
+ 'support crypto and fiat',
+ 'Stripe',
+ ],
+};
+
+function input(overrides: Partial = {}): CheckInput {
+ return {
+ body: 'Saw your note about cross-border settlement taking days. We hit the same thing.',
+ grounding: GROUNDING,
+ identityConfidence: 0.97,
+ minIdentityConfidence: 0.85,
+ ...overrides,
+ };
+}
+
+describe('grounding (PRD §14.1)', () => {
+ test('accepts a message built only from the evidence', () => {
+ const report = runChecks(input());
+ expect(report.passed).toBe(true);
+ expect(report.unsupported).toHaveLength(0);
+ });
+
+ test('rejects an invented product name', () => {
+ const report = runChecks(
+ input({ body: 'Saw your note on payouts. We built Fluxwire to solve exactly this.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ expect(failedChecks(report)).toContain('unsupported_claim');
+ expect(report.unsupported).toContain('Fluxwire');
+ });
+
+ test('rejects an invented statistic', () => {
+ const report = runChecks(
+ input({ body: 'Saw your note on payouts. Teams like yours cut fees by 40% with us.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ expect(report.unsupported).toContain('40%');
+ });
+
+ test('rejects a fabricated article the person never wrote', () => {
+ // The canonical failure from PRD §14.1.
+ const report = runChecks(
+ input({ body: 'I loved your recent article about payments infrastructure.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ });
+
+ test('permits a competitor the offering already names', () => {
+ const report = runChecks(
+ input({ body: 'Saw your Stripe note. Cross-border settlement was our pain too.' }),
+ );
+
+ expect(report.unsupported).not.toContain('Stripe');
+ });
+
+ test('permits the prospect’s own name and employer', () => {
+ const report = runChecks(
+ input({ body: 'Jane, your note on cross-border payouts at Acme rings true.' }),
+ );
+
+ expect(report.unsupported).toHaveLength(0);
+ });
+
+ test('flags a message that cites nothing at all', () => {
+ const report = runChecks(input({ body: 'Hi there, would you be open to a chat this week?' }));
+
+ expect(report.passed).toBe(false);
+ expect(failedChecks(report)).toContain('grounding');
+ });
+
+ test('is not fooled by paraphrase that still invents a specific', () => {
+ const report = runChecks(
+ input({ body: 'Your settlement delays sound painful. Our Ledger Bridge fixes them.' }),
+ );
+
+ expect(report.unsupported.length).toBeGreaterThan(0);
+ });
+});
+
+describe('claim extraction', () => {
+ test('picks out mid-sentence capitalised terms', () => {
+ expect(extractClaims('we tried Fluxwire and it worked')).toContain('Fluxwire');
+ });
+
+ test('ignores the first word of a sentence', () => {
+ // Sentence-initial capitals carry no information.
+ expect(extractClaims('Payments are hard.')).not.toContain('Payments');
+ });
+
+ test('picks out numbers and percentages', () => {
+ const claims = extractClaims('we saw 40% lower fees across 12 markets');
+ expect(claims).toContain('40%');
+ expect(claims).toContain('12');
+ });
+
+ test('picks out quoted phrases', () => {
+ expect(extractClaims('you said "fees are brutal" last week')).toContain('fees are brutal');
+ });
+
+ test('ignores common words even when capitalised', () => {
+ expect(extractClaims('the thing is Thanks matters')).not.toContain('Thanks');
+ });
+});
+
+describe('flattery (PRD §13.3)', () => {
+ test.each([
+ 'I loved your recent post about payouts.',
+ 'Big fan of your work on cross-border settlement.',
+ 'Your amazing thread on settlement delays caught my eye.',
+ 'Love what you are doing with payouts.',
+ ])('rejects %s', (body) => {
+ const report = runChecks(input({ body }));
+ expect(failedChecks(report)).toContain('excessive_flattery');
+ });
+
+ test('allows a plain factual opener', () => {
+ const report = runChecks(
+ input({ body: 'Your note on cross-border settlement caught my eye.' }),
+ );
+ expect(failedChecks(report)).not.toContain('excessive_flattery');
+ });
+});
+
+describe('spam patterns (PRD §18)', () => {
+ test.each([
+ 'Quick question about your cross-border settlement setup.',
+ 'Limited time offer on cross-border settlement.',
+ 'Just bumping this on cross-border settlement.',
+ 'Circling back on cross-border settlement.',
+ ])('rejects %s', (body) => {
+ expect(failedChecks(runChecks(input({ body })))).toContain('spam_pattern');
+ });
+
+ test('rejects a draft over the channel limit', () => {
+ const report = runChecks(
+ input({ body: `cross-border settlement ${'x'.repeat(300)}`, maxLength: 280 }),
+ );
+
+ expect(failedChecks(report)).toContain('spam_pattern');
+ });
+
+ test('rejects multiple links', () => {
+ const report = runChecks(
+ input({
+ body: 'Your cross-border settlement note: https://a.example and https://b.example',
+ }),
+ );
+
+ expect(failedChecks(report)).toContain('spam_pattern');
+ });
+
+ test('allows one link', () => {
+ const report = runChecks(
+ input({ body: 'On cross-border settlement, this helped us: https://a.example' }),
+ );
+
+ expect(failedChecks(report)).not.toContain('spam_pattern');
+ });
+});
+
+describe('sensitive categories (PRD §17.4)', () => {
+ test.each([
+ 'Saw your cross-border settlement note after your diagnosis.',
+ 'Congrats on the maternity leave — also, cross-border settlement.',
+ 'Sorry you were laid off. On cross-border settlement:',
+ ])('rejects %s', (body) => {
+ expect(failedChecks(runChecks(input({ body })))).toContain('sensitive_topic');
+ });
+
+ test('rejects a claim the customer prohibited', () => {
+ const report = runChecks(
+ input({
+ body: 'Your cross-border settlement note — we are the cheapest option anywhere.',
+ prohibitedClaims: ['cheapest'],
+ }),
+ );
+
+ expect(failedChecks(report)).toContain('sensitive_topic');
+ });
+});
+
+describe('identity confidence (PRD §48 Decision 4)', () => {
+ test('rejects a draft for a prospect below the threshold', () => {
+ const report = runChecks(input({ identityConfidence: 0.6, minIdentityConfidence: 0.85 }));
+ expect(failedChecks(report)).toContain('identity_confidence');
+ });
+
+ test('accepts exactly at the threshold', () => {
+ const report = runChecks(input({ identityConfidence: 0.85, minIdentityConfidence: 0.85 }));
+ expect(failedChecks(report)).not.toContain('identity_confidence');
+ });
+});
+
+describe('duplicate detection (PRD §18)', () => {
+ test('two drafts differing only by name share a fingerprint', () => {
+ const a = similarityFingerprint('Jane, your note on cross-border settlement rings true.', [
+ 'Jane',
+ 'Alex',
+ ]);
+ const b = similarityFingerprint('Alex, your note on cross-border settlement rings true.', [
+ 'Jane',
+ 'Alex',
+ ]);
+
+ // The classic mail-merge: this is exactly what must be caught.
+ expect(a).toBe(b);
+ });
+
+ test('genuinely different messages differ', () => {
+ const a = similarityFingerprint('Your note on cross-border settlement rings true.');
+ const b = similarityFingerprint('We published a teardown of payout latency last month.');
+
+ expect(a).not.toBe(b);
+ });
+
+ test('word order does not change the fingerprint', () => {
+ expect(similarityFingerprint('settlement delays payouts')).toBe(
+ similarityFingerprint('payouts settlement delays'),
+ );
+ });
+
+ test('rejects a draft matching one already sent', () => {
+ const body = 'Your note on cross-border settlement rings true.';
+ const report = runChecks(input({ body, priorDraftHashes: [similarityFingerprint(body)] }));
+
+ expect(failedChecks(report)).toContain('duplicate_similarity');
+ });
+});
+
+describe('report shape', () => {
+ test('covers every PRD §14.2 gate', () => {
+ const report = runChecks(input());
+ const checks = report.results.map((r) => r.check).sort();
+
+ expect(checks).toEqual([
+ 'duplicate_similarity',
+ 'excessive_flattery',
+ 'grounding',
+ 'identity_confidence',
+ 'policy',
+ 'sensitive_topic',
+ 'spam_pattern',
+ 'unsupported_claim',
+ ]);
+ });
+
+ test('every failure carries an explanation', () => {
+ const report = runChecks(input({ body: 'I loved your Fluxwire post — act now for 90% off!' }));
+
+ for (const result of report.results) {
+ if (!result.passed) expect(result.detail).toBeTruthy();
+ }
+ });
+
+ test('is deterministic', () => {
+ const state = input();
+ const first = runChecks(state);
+ for (let i = 0; i < 20; i += 1) {
+ expect(runChecks(state)).toEqual(first);
+ }
+ });
+});
+
+describe('findUnsupportedClaims', () => {
+ test('separates supported from unsupported', () => {
+ const { unsupported, allowed } = findUnsupportedClaims(
+ 'Jane, your Stripe note matches what we saw at Fluxwire.',
+ GROUNDING,
+ );
+
+ expect(allowed).toContain('Stripe');
+ expect(unsupported).toContain('Fluxwire');
+ });
+
+ test('reports nothing for prose with no specifics', () => {
+ const { unsupported } = findUnsupportedClaims(
+ 'we ran into something very similar and it took a while to sort out',
+ GROUNDING,
+ );
+
+ expect(unsupported).toHaveLength(0);
+ });
+});
diff --git a/packages/ai/src/checks.ts b/packages/ai/src/checks.ts
new file mode 100644
index 0000000..0b9766f
--- /dev/null
+++ b/packages/ai/src/checks.ts
@@ -0,0 +1,465 @@
+/**
+ * Message quality gates (PRD §14.1, §14.2).
+ *
+ * Every check here is deterministic. A model must never be the thing that
+ * decides whether its own output is grounded — that is the same mistake as
+ * letting an LLM decide policy, and it fails in exactly the way you cannot
+ * detect: confidently.
+ *
+ * The grounding check is the load-bearing one. It works by inverting the
+ * usual question. Rather than asking "is this claim true?" — undecidable — it
+ * asks "does every specific assertion in this draft appear in the evidence we
+ * stored?" Anything specific and unsourced is flagged, whether or not it
+ * happens to be true. That is the standard PRD §14.1 sets: no stored evidence,
+ * no claim.
+ */
+
+import type { QualityCheck, QualityCheckResult } from '@outreachgraph/domain';
+
+export interface GroundingContext {
+ /** Verbatim excerpts from the signals this draft may cite. */
+ readonly evidence: readonly string[];
+ /** Facts we hold about the person and their company. */
+ readonly facts: readonly string[];
+ /** The customer's own offering — safe to assert, they wrote it. */
+ readonly offering: readonly string[];
+}
+
+export interface CheckInput {
+ readonly body: string;
+ readonly subject?: string;
+ readonly grounding: GroundingContext;
+ readonly identityConfidence: number;
+ readonly minIdentityConfidence: number;
+ /** Similarity hashes of drafts already sent from this workspace. */
+ readonly priorDraftHashes?: readonly string[];
+ /** Channel ceiling, e.g. 280 for a public reply. */
+ readonly maxLength?: number;
+ /** Claims the customer has forbidden, from the voice profile. */
+ readonly prohibitedClaims?: readonly string[];
+}
+
+export interface CheckReport {
+ readonly results: readonly QualityCheckResult[];
+ readonly passed: boolean;
+ /** Set when grounding failed; the specific unsupported fragments. */
+ readonly unsupported: readonly string[];
+ readonly similarityHash: string;
+}
+
+/**
+ * Flattery the composer must not produce (PRD §13.3).
+ *
+ * These are the phrases that signal a message was generated rather than
+ * written — the "I loved your recent post" register, which reads as false
+ * familiarity precisely because it is.
+ */
+const FLATTERY_PATTERNS: readonly RegExp[] = [
+ /\bi (?:loved|really enjoyed|absolutely loved)\b/i,
+ /\bbig fan of\b/i,
+ /\bhuge fan\b/i,
+ /\byour (?:amazing|incredible|fantastic|brilliant|inspiring)\b/i,
+ /\blove what you(?:'re| are) doing\b/i,
+ /\bfollowing your work\b/i,
+ /\bcame across your (?:profile|work) and was (?:impressed|blown away)\b/i,
+];
+
+/** Manipulative urgency and mass-mail tells (PRD §13.3, §18). */
+const SPAM_PATTERNS: readonly RegExp[] = [
+ /\bact (?:now|fast)\b/i,
+ /\blimited time\b/i,
+ /\bdon'?t miss out\b/i,
+ /\bexclusive offer\b/i,
+ /\b(?:100%|guaranteed) (?:free|results)\b/i,
+ /\bquick question\b/i,
+ /\bjust bumping this\b/i,
+ /\bcircling back\b/i,
+ /\bper my last\b/i,
+ /\$\d+[kK]?\s*(?:in|of)\s*(?:savings|revenue)\b/i,
+];
+
+/**
+ * Sensitive categories that must never appear in outbound copy, even when a
+ * public post revealed them (PRD §17.4).
+ */
+const SENSITIVE_PATTERNS: readonly RegExp[] = [
+ /\b(?:diagnos(?:ed|is)|illness|cancer|depression|anxiety|disability|medication)\b/i,
+ /\b(?:pregnan|maternity|paternity)\w*\b/i,
+ /\b(?:divorce|bereave|passed away|funeral)\w*\b/i,
+ /\b(?:church|mosque|synagogue|muslim|christian|jewish|hindu)\b/i,
+ /\b(?:democrat|republican|liberal|conservative|voted for)\b/i,
+ /\b(?:laid off|fired|struggling financially|bankrupt)\b/i,
+ /\b(?:union|striking)\b/i,
+];
+
+/** Words too common to count as a specific claim. */
+const STOPWORDS = new Set([
+ 'the',
+ 'a',
+ 'an',
+ 'and',
+ 'or',
+ 'but',
+ 'if',
+ 'then',
+ 'of',
+ 'to',
+ 'in',
+ 'on',
+ 'at',
+ 'for',
+ 'with',
+ 'from',
+ 'by',
+ 'is',
+ 'are',
+ 'was',
+ 'were',
+ 'be',
+ 'been',
+ 'being',
+ 'it',
+ 'its',
+ 'this',
+ 'that',
+ 'these',
+ 'those',
+ 'as',
+ 'so',
+ 'i',
+ 'we',
+ 'you',
+ 'they',
+ 'he',
+ 'she',
+ 'me',
+ 'us',
+ 'them',
+ 'my',
+ 'our',
+ 'your',
+ 'their',
+ 'his',
+ 'her',
+ 'have',
+ 'has',
+ 'had',
+ 'do',
+ 'does',
+ 'did',
+ 'can',
+ 'could',
+ 'will',
+ 'would',
+ 'should',
+ 'may',
+ 'might',
+ 'must',
+ 'not',
+ 'no',
+ 'yes',
+ 'about',
+ 'into',
+ 'over',
+ 'under',
+ 'after',
+ 'before',
+ 'when',
+ 'while',
+ 'how',
+ 'what',
+ 'saw',
+ 'noticed',
+ 'ran',
+ 'into',
+ 'similar',
+ 'issue',
+ 'one',
+ 'pattern',
+ 'found',
+ 'useful',
+ 'testing',
+ 'team',
+ 'hi',
+ 'hey',
+ 'hello',
+ 'thanks',
+ 'thank',
+ 'cheers',
+ 'best',
+ 'regards',
+ 'happy',
+ 'help',
+ 'worth',
+ 'quick',
+ 'work',
+ 'works',
+ 'working',
+ 'build',
+ 'built',
+ 'building',
+ 'use',
+ 'used',
+ 'using',
+ 'make',
+ 'made',
+ 'get',
+ 'much',
+ 'many',
+ 'more',
+ 'most',
+ 'less',
+ 'same',
+ 'other',
+ 'another',
+ 'also',
+ 'just',
+ 'only',
+ 'still',
+ 'way',
+]);
+
+/**
+ * Distinct content words a draft must share with the evidence to count as
+ * grounded. Two is enough to rule out generic outreach ("would you be open to
+ * a chat?") without demanding the draft quote verbatim.
+ */
+const MIN_EVIDENCE_OVERLAP = 2;
+
+/** Count of distinct meaningful words the draft and the evidence share. */
+export function evidenceOverlap(body: string, evidence: readonly string[]): number {
+ if (evidence.length === 0) return 0;
+
+ const evidenceWords = new Set(
+ normalize(evidence.join(' '))
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w)),
+ );
+
+ const shared = new Set(
+ normalize(body)
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w) && evidenceWords.has(w)),
+ );
+
+ return shared.size;
+}
+
+export function runChecks(input: CheckInput): CheckReport {
+ const results: QualityCheckResult[] = [];
+ // Hash with the per-prospect facts removed, so the same template sent to
+ // different people collides rather than looking unique.
+ const similarityHash = similarityFingerprint(input.body, input.grounding.facts);
+
+ const { unsupported } = findUnsupportedClaims(input.body, input.grounding);
+
+ // Does the draft actually engage with the evidence? Measured as shared
+ // content words, not as extracted claims: a well-grounded message often
+ // paraphrases in plain lowercase prose ("settlement taking days") and has
+ // no capitalised or numeric specifics to extract at all.
+ const overlap = evidenceOverlap(input.body, input.grounding.evidence);
+ const groundingRequired = input.grounding.evidence.length > 0;
+
+ results.push({
+ check: 'grounding',
+ passed: !groundingRequired || overlap >= MIN_EVIDENCE_OVERLAP,
+ ...(groundingRequired && overlap < MIN_EVIDENCE_OVERLAP
+ ? { detail: 'the draft does not reference anything the prospect actually said' }
+ : {}),
+ });
+
+ results.push({
+ check: 'unsupported_claim',
+ passed: unsupported.length === 0,
+ ...(unsupported.length > 0
+ ? { detail: `no stored evidence supports: ${unsupported.slice(0, 5).join(', ')}` }
+ : {}),
+ });
+
+ results.push({
+ check: 'identity_confidence',
+ passed: input.identityConfidence >= input.minIdentityConfidence,
+ ...(input.identityConfidence < input.minIdentityConfidence
+ ? {
+ detail:
+ `identity confidence ${round(input.identityConfidence)} is below ` +
+ `${round(input.minIdentityConfidence)}`,
+ }
+ : {}),
+ });
+
+ const flattery = firstMatch(input.body, FLATTERY_PATTERNS);
+ results.push({
+ check: 'excessive_flattery',
+ passed: flattery === undefined,
+ ...(flattery ? { detail: `reads as manufactured warmth: "${flattery}"` } : {}),
+ });
+
+ const spam = firstMatch(input.body, SPAM_PATTERNS);
+ const tooLong = input.maxLength !== undefined && input.body.length > input.maxLength;
+ const linkCount = (input.body.match(/https?:\/\//g) ?? []).length;
+
+ results.push({
+ check: 'spam_pattern',
+ passed: spam === undefined && !tooLong && linkCount <= 1,
+ ...(spam
+ ? { detail: `spam phrasing: "${spam}"` }
+ : tooLong
+ ? { detail: `${input.body.length} characters exceeds the ${input.maxLength} limit` }
+ : linkCount > 1
+ ? { detail: `${linkCount} links; at most one is credible` }
+ : {}),
+ });
+
+ const duplicate = input.priorDraftHashes?.includes(similarityHash) ?? false;
+ results.push({
+ check: 'duplicate_similarity',
+ passed: !duplicate,
+ ...(duplicate ? { detail: 'this message has already been sent to someone else' } : {}),
+ });
+
+ const prohibited = (input.prohibitedClaims ?? []).find((claim) =>
+ input.body.toLowerCase().includes(claim.toLowerCase()),
+ );
+ const sensitive = firstMatch(input.body, SENSITIVE_PATTERNS);
+
+ results.push({
+ check: 'sensitive_topic',
+ passed: sensitive === undefined && prohibited === undefined,
+ ...(sensitive
+ ? { detail: `references a sensitive category: "${sensitive}"` }
+ : prohibited
+ ? { detail: `contains a prohibited claim: "${prohibited}"` }
+ : {}),
+ });
+
+ // The policy gate is evaluated by the engine, not here. Recording it keeps
+ // the §14.2 list complete and makes the omission visible if it is skipped.
+ results.push({ check: 'policy', passed: true, detail: 'evaluated by the policy engine' });
+
+ return {
+ results,
+ passed: results.every((r) => r.passed),
+ unsupported,
+ similarityHash,
+ };
+}
+
+/**
+ * Splits a draft into the specific assertions it makes, and checks each
+ * against the stored evidence.
+ *
+ * "Specific" means a capitalised term, a number, or a quoted phrase — the
+ * things that make a message feel researched, and therefore the things that
+ * do damage when invented. Ordinary prose is not checked; the sentence
+ * "we ran into something similar" asserts nothing checkable.
+ */
+export function findUnsupportedClaims(
+ body: string,
+ grounding: GroundingContext,
+): { unsupported: string[]; allowed: string[] } {
+ const haystack = normalize(
+ [...grounding.evidence, ...grounding.facts, ...grounding.offering].join(' '),
+ );
+
+ const unsupported: string[] = [];
+ const allowed: string[] = [];
+
+ for (const claim of extractClaims(body)) {
+ if (haystack.includes(normalize(claim))) allowed.push(claim);
+ else unsupported.push(claim);
+ }
+
+ return { unsupported: [...new Set(unsupported)], allowed: [...new Set(allowed)] };
+}
+
+/** Capitalised terms, quoted phrases and figures — the checkable specifics. */
+export function extractClaims(body: string): string[] {
+ const claims: string[] = [];
+
+ // Quoted phrases: an explicit claim about what someone said.
+ for (const match of body.matchAll(/[""']([^""']{4,120})[""']/g)) {
+ if (match[1]) claims.push(match[1]);
+ }
+
+ // Figures, including percentages and money — never safe to invent. No
+ // trailing \b: it would never match after '%', silently dropping the unit
+ // and comparing a bare "40" against the evidence.
+ for (const match of body.matchAll(/\b\d+(?:\.\d+)?%?/g)) {
+ if (match[0] && match[0].length > 1) claims.push(match[0]);
+ }
+
+ // Capitalised terms not at the start of a sentence: product and company
+ // names, technologies. Sentence-initial words are skipped because
+ // capitalisation there carries no information.
+ const sentences = body.split(/(?<=[.!?])\s+|\n+/);
+ for (const sentence of sentences) {
+ const words = sentence.trim().split(/\s+/);
+ for (let i = 1; i < words.length; i += 1) {
+ const word = words[i]!.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
+ if (word.length < 3) continue;
+ if (!/^\p{Lu}/u.test(word)) continue;
+ if (STOPWORDS.has(word.toLowerCase())) continue;
+ claims.push(word);
+ }
+ }
+
+ return [...new Set(claims)];
+}
+
+/**
+ * Order-insensitive fingerprint of a message's content words.
+ *
+ * `personalization` lists the per-prospect variables — name, title, employer —
+ * which are removed before hashing. That is what makes this catch the classic
+ * mail-merge: one template sent to a thousand people differs only in those
+ * tokens, so with them stripped every copy hashes identically (PRD §18).
+ * Without stripping, the varying name would make each copy look unique, which
+ * is precisely the case the check exists to find.
+ */
+export function similarityFingerprint(
+ body: string,
+ personalization: readonly string[] = [],
+): string {
+ const excluded = new Set(
+ personalization.flatMap((fact) => normalize(fact).split(/\s+/)).filter(Boolean),
+ );
+
+ const words = normalize(body)
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w) && !excluded.has(w))
+ .sort();
+
+ let hash = 2166136261;
+ for (const word of words) {
+ for (let i = 0; i < word.length; i += 1) {
+ hash ^= word.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+export function failedChecks(report: CheckReport): QualityCheck[] {
+ return report.results.filter((r) => !r.passed).map((r) => r.check);
+}
+
+function firstMatch(text: string, patterns: readonly RegExp[]): string | undefined {
+ for (const pattern of patterns) {
+ const match = pattern.exec(text);
+ if (match) return match[0];
+ }
+ return undefined;
+}
+
+function normalize(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s%.]/gu, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
diff --git a/packages/ai/src/composer.test.ts b/packages/ai/src/composer.test.ts
new file mode 100644
index 0000000..20ed8ef
--- /dev/null
+++ b/packages/ai/src/composer.test.ts
@@ -0,0 +1,292 @@
+import { describe, expect, test } from 'bun:test';
+import { composeDraft, type ComposeInput } from './composer';
+import { StubModel, type GenerateInput, type GenerateResult, type TextModel } from './model';
+
+function input(overrides: Partial = {}): ComposeInput {
+ return {
+ action: 'reply',
+ network: 'x',
+ offering: {
+ name: 'ExamplePay',
+ category: 'developer payments infrastructure',
+ valuePropositions: ['reduce payment integration time'],
+ likelyPains: ['high payment fees', 'international settlement'],
+ competitors: ['Stripe'],
+ },
+ prospect: {
+ displayName: 'Jane Smith',
+ firstName: 'Jane',
+ title: 'VP Engineering',
+ companyName: 'Acme',
+ identityConfidence: 0.97,
+ },
+ trigger: {
+ id: 'sig_1',
+ summary: 'Asked for alternatives for cross-border payouts',
+ evidence:
+ 'Does anyone have a good alternative to Stripe for cross-border payouts? ' +
+ 'Fees are brutal and settlement takes days.',
+ sourceUrl: 'https://x.com/janesmith/status/1',
+ network: 'x',
+ ageDescription: '4h ago',
+ },
+ minIdentityConfidence: 0.85,
+ ...overrides,
+ };
+}
+
+const GOOD_DRAFT = 'Settlement taking days was our breaking point too on cross-border payouts.';
+
+describe('grounding requirement (PRD §14.1)', () => {
+ test('refuses to write anything without stored evidence', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ const result = await composeDraft(model, input({ trigger: undefined }));
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('no_evidence');
+ // The model is never even called — there is nothing it could ground on.
+ expect(model.calls).toHaveLength(0);
+ });
+
+ test('refuses when the signal has a summary but no verbatim excerpt', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ const result = await composeDraft(
+ model,
+ input({
+ trigger: {
+ id: 'sig_2',
+ summary: 'Complained about payment fees',
+ network: 'x',
+ ageDescription: '2h ago',
+ },
+ }),
+ );
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('no_evidence');
+ });
+
+ test('accepts a draft built from the evidence', async () => {
+ const result = await composeDraft(new StubModel(GOOD_DRAFT), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ expect(result.groundedSignalIds).toEqual(['sig_1']);
+ });
+});
+
+describe('a hallucinating model is rejected, not surfaced', () => {
+ test('withholds a draft that invents a product', async () => {
+ // Both attempts hallucinate — the composer must give up, not degrade.
+ const model = new StubModel([
+ 'Your cross-border payouts note — Fluxwire fixed this for us.',
+ 'On cross-border settlement, Fluxwire solved it.',
+ ]);
+
+ const result = await composeDraft(model, input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('failed_checks');
+ expect(result.report?.unsupported).toContain('Fluxwire');
+ });
+
+ test('retries once and accepts a corrected second attempt', async () => {
+ const model = new StubModel(['Your payouts note — Fluxwire fixed this for us.', GOOD_DRAFT]);
+
+ const result = await composeDraft(model, input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.attempts).toBe(2);
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+
+ test('tells the model exactly what it invented on the retry', async () => {
+ const model = new StubModel(['Your payouts note — Fluxwire fixed this for us.', GOOD_DRAFT]);
+
+ await composeDraft(model, input());
+
+ expect(model.calls).toHaveLength(2);
+ // Naming the rejected fragment works better than restating the rule.
+ expect(model.calls[1]!.user).toContain('Fluxwire');
+ expect(model.calls[1]!.user).toContain('rejected');
+ });
+
+ test('honours maxAttempts of 1 — no retry', async () => {
+ // Mid-sentence, so the extractor sees it — a sentence-initial capital
+ // carries no information and is deliberately skipped.
+ const model = new StubModel(['We use Fluxwire for cross-border settlement.', GOOD_DRAFT]);
+ const result = await composeDraft(model, input({ maxAttempts: 1 }));
+
+ expect(result.ok).toBe(false);
+ expect(model.calls).toHaveLength(1);
+ });
+
+ test('withholds flattery even when otherwise grounded', async () => {
+ const model = new StubModel([
+ 'I loved your post about cross-border settlement delays.',
+ 'I loved your post about cross-border settlement delays.',
+ ]);
+
+ const result = await composeDraft(model, input());
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('prompt construction', () => {
+ test('gives the model the verbatim excerpt', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input());
+
+ expect(model.calls[0]!.user).toContain('Fees are brutal');
+ });
+
+ test('puts the stable offering and voice behind the cache breakpoint', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input());
+
+ const call = model.calls[0]!;
+ // The offering repeats for every prospect in a campaign, so it must sit
+ // in the cached prefix, not the per-prospect half.
+ expect(call.cachedPrefix).toContain('ExamplePay');
+ expect(call.cachedPrefix).not.toContain('Jane Smith');
+ expect(call.user).toContain('Jane Smith');
+ });
+
+ test('states the channel length limit', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input({ network: 'x' }));
+
+ expect(model.calls[0]!.system).toContain('280');
+ });
+
+ test('carries the voice profile into the cached prefix', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(
+ model,
+ input({
+ voice: {
+ style: 'technical',
+ instructions: 'Reference specific error modes.',
+ prohibitedClaims: ['guaranteed uptime'],
+ },
+ }),
+ );
+
+ const prefix = model.calls[0]!.cachedPrefix ?? '';
+ expect(prefix).toContain('Reference specific error modes.');
+ expect(prefix).toContain('guaranteed uptime');
+ });
+
+ test('tells the model a public reply has an audience', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input({ action: 'reply' }));
+
+ expect(model.calls[0]!.system).toContain('public reply');
+ });
+});
+
+describe('model failures', () => {
+ test('a refusal is not treated as a draft', async () => {
+ const refusing: TextModel = {
+ async generate(): Promise {
+ return {
+ text: '',
+ model: 'stub',
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ refused: true,
+ };
+ },
+ };
+
+ const result = await composeDraft(refusing, input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('model_refused');
+ });
+
+ test('an empty response is not treated as a draft', async () => {
+ const result = await composeDraft(new StubModel(' '), input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('empty');
+ });
+});
+
+describe('output cleanup', () => {
+ test('strips surrounding quotes the model adds despite instructions', async () => {
+ const result = await composeDraft(new StubModel(`"${GOOD_DRAFT}"`), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+
+ test('strips a code fence', async () => {
+ const result = await composeDraft(new StubModel(`\`\`\`\n${GOOD_DRAFT}\n\`\`\``), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+});
+
+describe('identity confidence', () => {
+ test('withholds a draft for a prospect below the threshold', async () => {
+ const result = await composeDraft(
+ new StubModel(GOOD_DRAFT),
+ input({
+ prospect: { displayName: 'Jane Smith', identityConfidence: 0.5 },
+ minIdentityConfidence: 0.85,
+ }),
+ );
+
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('duplicate suppression', () => {
+ test('withholds a draft identical to one already sent', async () => {
+ const first = await composeDraft(new StubModel(GOOD_DRAFT), input());
+ expect(first.ok).toBe(true);
+ if (!first.ok) return;
+
+ const second = await composeDraft(
+ new StubModel(GOOD_DRAFT),
+ input({ priorDraftHashes: [first.report.similarityHash] }),
+ );
+
+ expect(second.ok).toBe(false);
+ });
+});
+
+describe('determinism of the checked surface', () => {
+ test('the same model output always yields the same verdict', async () => {
+ const state = input();
+ const first = await composeDraft(new StubModel(GOOD_DRAFT), state);
+
+ for (let i = 0; i < 10; i += 1) {
+ const repeat = await composeDraft(new StubModel(GOOD_DRAFT), state);
+ expect(repeat.ok).toBe(first.ok);
+ }
+ });
+
+ test('prompts do not vary between identical calls', async () => {
+ const a = new StubModel(GOOD_DRAFT);
+ const b = new StubModel(GOOD_DRAFT);
+
+ await composeDraft(a, input());
+ await composeDraft(b, input());
+
+ const stripVolatile = (call: GenerateInput) => ({ ...call });
+ expect(stripVolatile(a.calls[0]!)).toEqual(stripVolatile(b.calls[0]!));
+ });
+});
diff --git a/packages/ai/src/composer.ts b/packages/ai/src/composer.ts
new file mode 100644
index 0000000..a81cb72
--- /dev/null
+++ b/packages/ai/src/composer.ts
@@ -0,0 +1,297 @@
+/**
+ * The outreach composer (PRD §14, §20.7).
+ *
+ * The composer's only job is wording. It does not decide who to contact
+ * (scoring), where (policy), or why (the recommendation engine) — by the time
+ * it runs, all three are settled, and it receives their output as fixed
+ * context.
+ *
+ * Two properties are enforced structurally rather than requested politely:
+ *
+ * 1. **It can only see grounded inputs.** The prompt is assembled from
+ * stored evidence, stored facts, and the customer's own offering. There
+ * is no path by which the model learns something it may not cite.
+ * 2. **Its output is checked, not trusted.** Every draft runs the §14.2
+ * gates before anyone sees it. A draft that invents a specific is
+ * rejected — including on a retry — rather than shown with a warning.
+ */
+
+import type { ActionKind, Network, OutreachStyle } from '@outreachgraph/domain';
+import { runChecks, type CheckReport, type GroundingContext } from './checks';
+import type { TextModel } from './model';
+
+export interface OfferingContext {
+ readonly name: string;
+ readonly category: string;
+ readonly valuePropositions: readonly string[];
+ readonly likelyPains: readonly string[];
+ readonly competitors: readonly string[];
+}
+
+export interface ProspectContext {
+ readonly displayName: string;
+ readonly firstName?: string;
+ readonly title?: string;
+ readonly companyName?: string;
+ readonly identityConfidence: number;
+}
+
+/** The signal that justified contact. Its excerpt is the only quotable text. */
+export interface TriggerContext {
+ readonly id: string;
+ readonly summary: string;
+ readonly evidence?: string;
+ readonly sourceUrl?: string;
+ readonly network: Network;
+ readonly ageDescription: string;
+}
+
+export interface VoiceContext {
+ readonly style: OutreachStyle;
+ readonly instructions?: string;
+ readonly samples?: readonly string[];
+ readonly maxWords?: number;
+ readonly prohibitedClaims?: readonly string[];
+}
+
+export interface ComposeInput {
+ readonly action: ActionKind;
+ readonly network: Network;
+ readonly offering: OfferingContext;
+ readonly prospect: ProspectContext;
+ readonly trigger?: TriggerContext;
+ readonly voice?: VoiceContext;
+ readonly minIdentityConfidence: number;
+ readonly priorDraftHashes?: readonly string[];
+ /** Retries on a failed grounding check. Zero disables retrying. */
+ readonly maxAttempts?: number;
+}
+
+export type ComposeResult =
+ | {
+ readonly ok: true;
+ readonly body: string;
+ readonly report: CheckReport;
+ readonly groundedSignalIds: readonly string[];
+ readonly model: string;
+ readonly attempts: number;
+ }
+ | {
+ readonly ok: false;
+ readonly reason: 'no_evidence' | 'failed_checks' | 'model_refused' | 'empty';
+ readonly report?: CheckReport;
+ readonly attempts: number;
+ readonly lastBody?: string;
+ };
+
+/** Channel ceilings. A public reply that gets truncated is worse than none. */
+const LENGTH_LIMITS: Partial> = {
+ x: 280,
+ bluesky: 300,
+ linkedin: 700,
+ github: 500,
+};
+
+const STYLE_GUIDANCE: Record = {
+ relationship_first: 'Open a conversation, not a pitch. Do not mention buying, demos, or calls.',
+ concise_founder: 'Two or three sentences, plain and direct, founder to founder.',
+ technical: 'Speak to the specific technical problem. Assume real expertise.',
+ helpful_no_pitch: 'Be useful and mention nothing you sell.',
+ direct: 'State the reason for the message in the first sentence.',
+ community_first: 'Contribute to the conversation as a peer already in it.',
+ custom: 'Follow the supplied voice instructions exactly.',
+};
+
+export async function composeDraft(model: TextModel, input: ComposeInput): Promise {
+ // Without an excerpt there is nothing quotable, and a personalised message
+ // built on a summary alone is exactly the fabrication §14.1 forbids.
+ if (!input.trigger?.evidence) {
+ return { ok: false, reason: 'no_evidence', attempts: 0 };
+ }
+
+ const grounding = buildGrounding(input);
+ const maxAttempts = Math.max(1, input.maxAttempts ?? 2);
+
+ let lastReport: CheckReport | undefined;
+ let lastBody: string | undefined;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const generated = await model.generate({
+ cachedPrefix: buildCachedPrefix(input),
+ system: buildSystem(input),
+ user: buildUser(input, attempt > 1 ? lastReport : undefined),
+ maxTokens: 1024,
+ });
+
+ if (generated.refused) return { ok: false, reason: 'model_refused', attempts: attempt };
+
+ const body = stripWrapper(generated.text);
+ if (!body) return { ok: false, reason: 'empty', attempts: attempt };
+
+ lastBody = body;
+
+ const report = runChecks({
+ body,
+ grounding,
+ identityConfidence: input.prospect.identityConfidence,
+ minIdentityConfidence: input.minIdentityConfidence,
+ ...(input.priorDraftHashes ? { priorDraftHashes: input.priorDraftHashes } : {}),
+ ...(LENGTH_LIMITS[input.network] === undefined
+ ? {}
+ : { maxLength: LENGTH_LIMITS[input.network]! }),
+ ...(input.voice?.prohibitedClaims ? { prohibitedClaims: input.voice.prohibitedClaims } : {}),
+ });
+
+ lastReport = report;
+
+ if (report.passed) {
+ return {
+ ok: true,
+ body,
+ report,
+ groundedSignalIds: [input.trigger.id],
+ model: generated.model,
+ attempts: attempt,
+ };
+ }
+ }
+
+ // A draft that fails its gates is withheld, not shown with a warning. The
+ // reviewer is the last line of defence, and a bad draft next to a caveat is
+ // still a bad draft they might approve.
+ return {
+ ok: false,
+ reason: 'failed_checks',
+ ...(lastReport ? { report: lastReport } : {}),
+ ...(lastBody ? { lastBody } : {}),
+ attempts: maxAttempts,
+ };
+}
+
+/** Everything the model may treat as true. Nothing else exists to it. */
+function buildGrounding(input: ComposeInput): GroundingContext {
+ const facts = [
+ input.prospect.displayName,
+ input.prospect.firstName ?? '',
+ input.prospect.title ?? '',
+ input.prospect.companyName ?? '',
+ input.trigger?.network ?? '',
+ ].filter(Boolean);
+
+ return {
+ evidence: input.trigger?.evidence ? [input.trigger.evidence, input.trigger.summary] : [],
+ facts,
+ offering: [
+ input.offering.name,
+ input.offering.category,
+ ...input.offering.valuePropositions,
+ ...input.offering.likelyPains,
+ ...input.offering.competitors,
+ ],
+ };
+}
+
+/**
+ * The stable half of the prompt.
+ *
+ * Identical for every prospect in a campaign, so it sits behind the cache
+ * breakpoint — see `ClaudeModel.generate`.
+ */
+function buildCachedPrefix(input: ComposeInput): string {
+ const voice = input.voice;
+
+ return [
+ 'You write short outreach messages on behalf of a business.',
+ '',
+ 'Absolute rules:',
+ '- Only state things supported by the CONTEXT you are given. If a fact is not there, it does not exist.',
+ '- Never claim to have read, watched, or used anything unless the CONTEXT shows it.',
+ '- Never invent numbers, product names, companies, or shared experiences.',
+ '- No flattery. Do not open by praising them or their work.',
+ '- No urgency, no scarcity, no manufactured enthusiasm.',
+ '- Write as one person to another. Not marketing copy.',
+ '- Output only the message. No subject line, no signature, no preamble, no quotation marks.',
+ '',
+ `About the business: ${input.offering.name}, ${input.offering.category}.`,
+ input.offering.valuePropositions.length > 0
+ ? `What it does: ${input.offering.valuePropositions.join('; ')}.`
+ : '',
+ input.offering.likelyPains.length > 0
+ ? `Problems it addresses: ${input.offering.likelyPains.join('; ')}.`
+ : '',
+ '',
+ `Style: ${STYLE_GUIDANCE[voice?.style ?? 'relationship_first']}`,
+ voice?.instructions ? `Additional voice guidance: ${voice.instructions}` : '',
+ voice?.samples?.length
+ ? `Match the register of these samples:\n${voice.samples.map((s) => `- ${s}`).join('\n')}`
+ : '',
+ voice?.prohibitedClaims?.length ? `Never claim: ${voice.prohibitedClaims.join('; ')}.` : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+/** The per-prospect half — everything after the cache breakpoint. */
+function buildSystem(input: ComposeInput): string {
+ const limit = LENGTH_LIMITS[input.network];
+ const words = input.voice?.maxWords;
+
+ return [
+ `Channel: ${input.network}. Action: ${input.action.replace(/_/g, ' ')}.`,
+ limit ? `Hard limit: ${limit} characters.` : '',
+ words ? `Aim for at most ${words} words.` : 'Aim for at most 60 words.',
+ input.action === 'reply' || input.action === 'comment'
+ ? 'This is a public reply in an existing thread. Other people will read it. Be useful to them too.'
+ : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+function buildUser(input: ComposeInput, failed?: CheckReport): string {
+ const trigger = input.trigger!;
+ const name = input.prospect.firstName ?? input.prospect.displayName;
+
+ const sections = [
+ 'CONTEXT — the only facts you may use:',
+ `Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${
+ input.prospect.companyName ? ` at ${input.prospect.companyName}` : ''
+ }`,
+ `What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`,
+ `Their exact words:\n"""\n${trigger.evidence}\n"""`,
+ '',
+ `Write a message to ${name} responding to what they said.`,
+ 'Reference their words specifically enough that it could not have been sent to anyone else.',
+ ];
+
+ if (failed) {
+ // Naming the exact rejected fragments works far better than repeating the
+ // rule — the model can see what it invented.
+ const problems = failed.results
+ .filter((r) => !r.passed && r.detail)
+ .map((r) => `- ${r.detail}`)
+ .join('\n');
+
+ sections.push(
+ '',
+ 'Your previous attempt was rejected:',
+ problems,
+ failed.unsupported.length > 0
+ ? `Remove these entirely — nothing supports them: ${failed.unsupported.join(', ')}.`
+ : '',
+ 'Rewrite using only the CONTEXT above.',
+ );
+ }
+
+ return sections.filter(Boolean).join('\n');
+}
+
+/** Models sometimes wrap output in quotes or a code fence despite instructions. */
+function stripWrapper(text: string): string {
+ let out = text.trim();
+ out = out.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '');
+ if (out.length > 1 && /^["'"']/.test(out) && /["'"']$/.test(out)) {
+ out = out.slice(1, -1);
+ }
+ return out.trim();
+}
diff --git a/packages/ai/src/draft.test.ts b/packages/ai/src/draft.test.ts
new file mode 100644
index 0000000..430f609
--- /dev/null
+++ b/packages/ai/src/draft.test.ts
@@ -0,0 +1,184 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { StubModel } from '@outreachgraph/ai';
+import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed';
+import { draftForRecommendation } from './draft';
+
+let active: SeededDatabase | undefined;
+
+afterEach(() => {
+ active?.cleanup();
+ active = undefined;
+});
+
+/**
+ * The seed fixture already ships a draft for its recommendation, which would
+ * short-circuit the idempotence guard — remove it so each test composes.
+ */
+async function fixture(label: string): Promise {
+ active = await seedDatabase(`draft-${label}`);
+ await active.db.execute({ sql: 'DELETE FROM drafts WHERE id = ?', args: [SEED.draftId] });
+ await active.db.execute({
+ sql: 'UPDATE recommendations SET draft_id = NULL WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+ return active;
+}
+
+const GOOD = 'Cross-border payouts and settlement delays were our pain too.';
+
+describe('composing from stored evidence', () => {
+ test('writes a draft and links it to the recommendation', async () => {
+ const { db } = await fixture('happy');
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ expect(result.ok).toBe(true);
+ expect(result.draftId).toMatch(/^drf_/);
+
+ const draft = await db.execute({
+ sql: 'SELECT body, grounded_signal_ids, similarity_hash, model FROM drafts WHERE id = ?',
+ args: [result.draftId!],
+ });
+
+ expect(draft.rows[0]?.body).toBe(GOOD);
+ expect(JSON.parse(String(draft.rows[0]?.grounded_signal_ids))).toEqual([SEED.signalId]);
+ expect(draft.rows[0]?.similarity_hash).toBeTruthy();
+
+ const rec = await db.execute({
+ sql: 'SELECT draft_id FROM recommendations WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+ expect(rec.rows[0]?.draft_id).toBe(result.draftId!);
+ });
+
+ test('stores the quality report alongside the body', async () => {
+ const { db } = await fixture('report');
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ const draft = await db.execute({
+ sql: 'SELECT checks_json FROM drafts WHERE id = ?',
+ args: [result.draftId!],
+ });
+
+ const checks = JSON.parse(String(draft.rows[0]?.checks_json));
+ expect(checks.length).toBeGreaterThan(0);
+ expect(checks.every((c: { passed: boolean }) => c.passed)).toBe(true);
+ });
+
+ test('is idempotent — a second run reuses the existing draft', async () => {
+ const { db } = await fixture('idempotent');
+
+ const first = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ const model = new StubModel(GOOD);
+ const second = await draftForRecommendation(db, model, SEED.recommendationId);
+
+ expect(second.draftId).toBe(first.draftId!);
+ // No tokens spent rewriting a message that may already be approved.
+ expect(model.calls).toHaveLength(0);
+ });
+});
+
+describe('withholding rather than fabricating', () => {
+ test('writes no draft when the model invents a product', async () => {
+ const { db } = await fixture('hallucination');
+
+ const result = await draftForRecommendation(
+ db,
+ new StubModel([
+ 'Your payouts note — we fixed it with Fluxwire.',
+ 'On settlement delays, Fluxwire handled it.',
+ ]),
+ SEED.recommendationId,
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('failed_checks');
+ expect(result.unsupported).toContain('Fluxwire');
+
+ const drafts = await db.execute('SELECT count(*) AS n FROM drafts');
+ expect(Number(drafts.rows[0]?.n)).toBe(0);
+ });
+
+ test('writes no draft when the signal has no verbatim evidence', async () => {
+ const { db } = await fixture('noevidence');
+ await db.execute({
+ sql: 'UPDATE signals SET evidence = NULL WHERE id = ?',
+ args: [SEED.signalId],
+ });
+
+ const model = new StubModel(GOOD);
+ const result = await draftForRecommendation(db, model, SEED.recommendationId);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('no_evidence');
+ // The model is never called — there is nothing it could ground on.
+ expect(model.calls).toHaveLength(0);
+ });
+
+ test('writes no draft when the recommendation has no trigger', async () => {
+ const { db } = await fixture('notrigger');
+ await db.execute({
+ sql: 'UPDATE recommendations SET trigger_signal_id = NULL WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('no_trigger_signal');
+ });
+
+ test('writes no draft for a prospect below the identity threshold', async () => {
+ const { db } = await fixture('lowconf');
+ await db.execute({
+ sql: 'UPDATE people SET identity_confidence = 0.4 WHERE id = ?',
+ args: [SEED.personId],
+ });
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('cross-prospect duplicate suppression (PRD §18)', () => {
+ test('refuses a second copy of a message already drafted', async () => {
+ const { db } = await fixture('duplicate');
+ const stamp = new Date().toISOString();
+
+ // A second prospect and recommendation in the same workspace.
+ await db.batch([
+ {
+ sql: `INSERT INTO people (id, display_name, first_name, identity_confidence, status,
+ outreach_eligible, believed_minor, created_at, updated_at)
+ VALUES ('per_two', 'Alex Chen', 'Alex', 0.97, 'active', 1, 0, ?, ?)`,
+ args: [stamp, stamp],
+ },
+ {
+ sql: `INSERT INTO signals (id, workspace_id, person_id, network, signal_type, summary,
+ evidence, source_timestamp, observed_at, confidence, relevance, sentiment)
+ VALUES ('sig_two', ?, 'per_two', 'x', 'recommendation_request',
+ 'Asked about cross-border payouts',
+ 'Does anyone have a good alternative for cross-border payouts? Settlement takes days.',
+ ?, ?, 0.9, 0.9, 'neutral')`,
+ args: [SEED.workspaceId, stamp, stamp],
+ },
+ {
+ sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action,
+ network, priority, reason, trigger_signal_id, policy_status, policy_version,
+ expected_goal, status, created_at)
+ VALUES ('rec_two', ?, ?, 'per_two', 'reply', 'x', 80, 'same ask',
+ 'sig_two', 'allow_with_approval', '2026-08-11', 'start_conversation', 'pending', ?)`,
+ args: [SEED.workspaceId, SEED.campaignId, stamp],
+ },
+ ]);
+
+ const first = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ expect(first.ok).toBe(true);
+
+ // The same wording for a different person is the mail-merge case.
+ const second = await draftForRecommendation(db, new StubModel([GOOD, GOOD]), 'rec_two');
+
+ expect(second.ok).toBe(false);
+ expect(second.reason).toBe('failed_checks');
+ });
+});
diff --git a/packages/ai/src/draft.ts b/packages/ai/src/draft.ts
new file mode 100644
index 0000000..b81b0b4
--- /dev/null
+++ b/packages/ai/src/draft.ts
@@ -0,0 +1,239 @@
+/**
+ * Turning a recommendation into a draft (PRD §14).
+ *
+ * Runs after the recommendation exists, so the composer inherits a settled
+ * decision: who, why, which channel, and which signal it may quote. It writes
+ * a draft row only when every §14.2 gate passes.
+ *
+ * A missing draft is a working state, not a failure. The approval card still
+ * shows the prospect, the evidence and the recommended action; the reviewer
+ * writes the message themselves. That is strictly better than showing a
+ * fabricated one.
+ */
+
+import { newId, type ActionKind, type Network, type OutreachStyle } from '@outreachgraph/domain';
+import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';
+import { composeDraft, type ComposeResult, type TextModel } from '@outreachgraph/ai';
+
+export interface DraftResult {
+ readonly ok: boolean;
+ readonly draftId?: string;
+ readonly reason?: string;
+ readonly unsupported?: readonly string[];
+}
+
+/**
+ * Composes and stores a draft for one recommendation.
+ *
+ * Idempotent: a recommendation that already has a draft is left alone, so a
+ * retried pipeline run does not spend tokens rewriting an approved message.
+ */
+export async function draftForRecommendation(
+ db: Client,
+ model: TextModel,
+ recommendationId: string,
+): Promise {
+ const existing = await queryOne<{ id: string }>(
+ db,
+ 'SELECT id FROM drafts WHERE recommendation_id = ? LIMIT 1',
+ [recommendationId],
+ );
+ if (existing) return { ok: true, draftId: existing.id };
+
+ const recommendation = await queryOne<{
+ id: string;
+ workspace_id: string;
+ campaign_id: string;
+ person_id: string;
+ action: string;
+ network: string;
+ trigger_signal_id: string | null;
+ }>(
+ db,
+ `SELECT id, workspace_id, campaign_id, person_id, action, network, trigger_signal_id
+ FROM recommendations WHERE id = ?`,
+ [recommendationId],
+ );
+ if (!recommendation) return { ok: false, reason: 'no_recommendation' };
+
+ // No trigger means nothing to quote, and §14.1 forbids personalising
+ // without evidence.
+ if (!recommendation.trigger_signal_id) return { ok: false, reason: 'no_trigger_signal' };
+
+ const signal = await queryOne<{
+ id: string;
+ summary: string;
+ evidence: string | null;
+ source_url: string | null;
+ network: string;
+ source_timestamp: string | null;
+ observed_at: string;
+ }>(
+ db,
+ `SELECT id, summary, evidence, source_url, network, source_timestamp, observed_at
+ FROM signals WHERE id = ?`,
+ [recommendation.trigger_signal_id],
+ );
+ if (!signal?.evidence) return { ok: false, reason: 'no_evidence' };
+
+ const person = await queryOne<{
+ display_name: string;
+ first_name: string | null;
+ current_title: string | null;
+ current_company_id: string | null;
+ identity_confidence: number;
+ }>(
+ db,
+ `SELECT display_name, first_name, current_title, current_company_id, identity_confidence
+ FROM people WHERE id = ?`,
+ [recommendation.person_id],
+ );
+ if (!person) return { ok: false, reason: 'no_person' };
+
+ const company = person.current_company_id
+ ? await queryOne<{ name: string }>(db, 'SELECT name FROM companies WHERE id = ?', [
+ person.current_company_id,
+ ])
+ : undefined;
+
+ const offering = await queryOne<{
+ name: string;
+ category: string;
+ value_propositions: string;
+ likely_pains: string;
+ competitors: string;
+ }>(
+ db,
+ `SELECT o.name, o.category, o.value_propositions, o.likely_pains, o.competitors
+ FROM offerings o JOIN campaigns c ON c.offering_id = o.id WHERE c.id = ?`,
+ [recommendation.campaign_id],
+ );
+ if (!offering) return { ok: false, reason: 'no_offering' };
+
+ const voice = await queryOne<{
+ style: string;
+ instructions: string | null;
+ samples: string;
+ max_words: number | null;
+ prohibited_claims: string;
+ }>(
+ db,
+ `SELECT v.style, v.instructions, v.samples, v.max_words, v.prohibited_claims
+ FROM voice_profiles v JOIN campaigns c ON c.voice_profile_id = v.id WHERE c.id = ?`,
+ [recommendation.campaign_id],
+ );
+
+ const workspace = await queryOne<{ min_outreach_confidence: number }>(
+ db,
+ 'SELECT min_outreach_confidence FROM workspaces WHERE id = ?',
+ [recommendation.workspace_id],
+ );
+
+ // Every message already sent from this workspace, so a near-identical
+ // second copy is caught before a human ever sees it (PRD §18).
+ const priorHashes = await queryAll<{ similarity_hash: string }>(
+ db,
+ `SELECT DISTINCT similarity_hash FROM drafts
+ WHERE workspace_id = ? AND similarity_hash IS NOT NULL`,
+ [recommendation.workspace_id],
+ );
+
+ const result: ComposeResult = await composeDraft(model, {
+ action: recommendation.action as ActionKind,
+ network: recommendation.network as Network,
+ offering: {
+ name: offering.name,
+ category: offering.category,
+ valuePropositions: parseArray(offering.value_propositions),
+ likelyPains: parseArray(offering.likely_pains),
+ competitors: parseArray(offering.competitors),
+ },
+ prospect: {
+ displayName: person.display_name,
+ ...(person.first_name ? { firstName: person.first_name } : {}),
+ ...(person.current_title ? { title: person.current_title } : {}),
+ ...(company?.name ? { companyName: company.name } : {}),
+ identityConfidence: person.identity_confidence,
+ },
+ trigger: {
+ id: signal.id,
+ summary: signal.summary,
+ evidence: signal.evidence,
+ ...(signal.source_url ? { sourceUrl: signal.source_url } : {}),
+ network: signal.network as Network,
+ ageDescription: describeAge(signal.source_timestamp ?? signal.observed_at),
+ },
+ ...(voice
+ ? {
+ voice: {
+ style: voice.style as OutreachStyle,
+ ...(voice.instructions ? { instructions: voice.instructions } : {}),
+ samples: parseArray(voice.samples),
+ ...(voice.max_words == null ? {} : { maxWords: voice.max_words }),
+ prohibitedClaims: parseArray(voice.prohibited_claims),
+ },
+ }
+ : {}),
+ minIdentityConfidence: workspace?.min_outreach_confidence ?? 0.85,
+ priorDraftHashes: priorHashes.map((r) => r.similarity_hash),
+ });
+
+ if (!result.ok) {
+ return {
+ ok: false,
+ reason: result.reason,
+ ...(result.report?.unsupported ? { unsupported: result.report.unsupported } : {}),
+ };
+ }
+
+ const draftId = newId('draft');
+ const stamp = now();
+
+ await db.batch([
+ {
+ sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, body, grounded_signal_ids,
+ checks_json, similarity_hash, model, edited_by_user, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
+ args: [
+ draftId,
+ recommendation.workspace_id,
+ recommendation.id,
+ result.body,
+ JSON.stringify(result.groundedSignalIds),
+ JSON.stringify(result.report.results),
+ result.report.similarityHash,
+ result.model,
+ stamp,
+ stamp,
+ ],
+ },
+ {
+ sql: 'UPDATE recommendations SET draft_id = ? WHERE id = ?',
+ args: [draftId, recommendation.id],
+ },
+ ]);
+
+ return { ok: true, draftId };
+}
+
+function describeAge(timestamp: string): string {
+ const then = Date.parse(timestamp);
+ if (Number.isNaN(then)) return 'recently';
+
+ const hours = (Date.now() - then) / 3_600_000;
+ if (hours < 1) return 'in the last hour';
+ if (hours < 24) return `${Math.round(hours)}h ago`;
+
+ const days = Math.round(hours / 24);
+ return days === 1 ? 'yesterday' : `${days} days ago`;
+}
+
+function parseArray(raw: string | undefined): string[] {
+ if (!raw) return [];
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.map(String) : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts
new file mode 100644
index 0000000..2f1fb4b
--- /dev/null
+++ b/packages/ai/src/index.ts
@@ -0,0 +1,41 @@
+/**
+ * `@outreachgraph/ai` — the only package that talks to a model (PRD §14, §20.7).
+ *
+ * Everything a model produces here passes deterministic gates before anyone
+ * sees it. Nothing a model says decides a policy outcome, an identity merge,
+ * or a score.
+ */
+
+export {
+ extractClaims,
+ failedChecks,
+ findUnsupportedClaims,
+ runChecks,
+ similarityFingerprint,
+ type CheckInput,
+ type CheckReport,
+ type GroundingContext,
+} from './checks';
+
+export {
+ ClaudeModel,
+ DEFAULT_MODEL,
+ MissingApiKeyError,
+ StubModel,
+ type ClaudeModelOptions,
+ type GenerateInput,
+ type GenerateResult,
+ type TextModel,
+} from './model';
+
+export {
+ composeDraft,
+ type ComposeInput,
+ type ComposeResult,
+ type OfferingContext,
+ type ProspectContext,
+ type TriggerContext,
+ type VoiceContext,
+} from './composer';
+
+export { draftForRecommendation, type DraftResult } from './draft';
diff --git a/packages/ai/src/model.ts b/packages/ai/src/model.ts
new file mode 100644
index 0000000..af425a3
--- /dev/null
+++ b/packages/ai/src/model.ts
@@ -0,0 +1,143 @@
+/**
+ * The model boundary (PRD §1.1 principle 8, §20).
+ *
+ * One place in the codebase talks to an LLM. Everything else — policy,
+ * identity, scoring, the quality gates — is deterministic and stays that way.
+ * Keeping the surface this narrow is what makes "no LLM decides a merge or a
+ * policy outcome" an architectural fact rather than a guideline.
+ */
+
+import Anthropic from '@anthropic-ai/sdk';
+
+/** Opus 5 is the default; a workspace may pin a cheaper model per campaign. */
+export const DEFAULT_MODEL = 'claude-opus-5';
+
+export interface GenerateInput {
+ readonly system: string;
+ readonly user: string;
+ readonly maxTokens?: number;
+ readonly model?: string;
+ /**
+ * Stable prefix for prompt caching. The offering and voice profile repeat
+ * across every draft in a campaign, so they belong here rather than inlined
+ * into `system` where a per-prospect edit would invalidate the cache.
+ */
+ readonly cachedPrefix?: string;
+}
+
+export interface GenerateResult {
+ readonly text: string;
+ readonly model: string;
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cachedTokens: number;
+ /** True when the model declined; the caller must not treat text as a draft. */
+ readonly refused: boolean;
+}
+
+export interface TextModel {
+ generate(input: GenerateInput): Promise;
+}
+
+export class MissingApiKeyError extends Error {
+ constructor() {
+ super('ANTHROPIC_API_KEY is not configured');
+ this.name = 'MissingApiKeyError';
+ }
+}
+
+export interface ClaudeModelOptions {
+ readonly apiKey?: string;
+ readonly model?: string;
+ readonly client?: Anthropic;
+}
+
+export class ClaudeModel implements TextModel {
+ readonly #client: Anthropic;
+ readonly #model: string;
+
+ constructor(options: ClaudeModelOptions = {}) {
+ const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
+ if (!options.client && !apiKey) throw new MissingApiKeyError();
+
+ this.#client = options.client ?? new Anthropic({ apiKey });
+ this.#model = options.model ?? DEFAULT_MODEL;
+ }
+
+ async generate(input: GenerateInput): Promise {
+ const model = input.model ?? this.#model;
+
+ const system = input.cachedPrefix
+ ? [
+ // The breakpoint sits at the end of the stable prefix, so the
+ // per-prospect half after it can change freely without a cache miss.
+ {
+ type: 'text' as const,
+ text: input.cachedPrefix,
+ cache_control: { type: 'ephemeral' as const },
+ },
+ { type: 'text' as const, text: input.system },
+ ]
+ : input.system;
+
+ // `output_config: { effort: "low" }` would suit this task — drafting is
+ // short and well-specified, and lower effort produces less of the
+ // elaboration the quality gates then reject. It is omitted because the
+ // installed SDK does not type it, and an unverifiable request parameter
+ // that 400s means no drafts at all. Add it once the SDK catches up.
+ const response = await this.#client.messages.create({
+ model,
+ max_tokens: input.maxTokens ?? 2048,
+ system,
+ messages: [{ role: 'user', content: input.user }],
+ });
+
+ // A refusal returns HTTP 200 with an empty or partial body — reading
+ // content[0] without checking stop_reason is how that becomes a crash.
+ const refused = response.stop_reason === 'refusal';
+
+ const text = response.content
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
+ .map((block) => block.text)
+ .join('')
+ .trim();
+
+ return {
+ text,
+ model: response.model,
+ inputTokens: response.usage.input_tokens,
+ outputTokens: response.usage.output_tokens,
+ cachedTokens: response.usage.cache_read_input_tokens ?? 0,
+ refused,
+ };
+ }
+}
+
+/**
+ * A model that returns a fixed response.
+ *
+ * Composer tests assert on prompt construction and the quality gates, neither
+ * of which should depend on a live API call — or cost money in CI.
+ */
+export class StubModel implements TextModel {
+ readonly #responses: string[];
+ readonly calls: GenerateInput[] = [];
+
+ constructor(responses: string | readonly string[]) {
+ this.#responses = typeof responses === 'string' ? [responses] : [...responses];
+ }
+
+ async generate(input: GenerateInput): Promise {
+ this.calls.push(input);
+ const text = this.#responses.length > 1 ? this.#responses.shift()! : this.#responses[0]!;
+
+ return {
+ text,
+ model: 'stub',
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ refused: false,
+ };
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 6733d9e..be3b170 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,9 @@
"@outreachgraph/scoring": ["./packages/scoring/src/index.ts"],
"@outreachgraph/policy": ["./packages/policy/src/index.ts"],
"@outreachgraph/providers": ["./packages/providers/src/index.ts"],
- "@outreachgraph/contracts": ["./packages/contracts/src/index.ts"]
+ "@outreachgraph/contracts": ["./packages/contracts/src/index.ts"],
+ "@outreachgraph/ai": ["./packages/ai/src/index.ts"],
+ "@outreachgraph/recommend": ["./packages/recommend/src/index.ts"]
}
},
"include": ["packages/*/src/**/*.ts", "apps/*/src/**/*.ts"],
From edc4f04e06eb0ee0c9599cc42be9e2f1109a353f Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Wed, 12 Aug 2026 01:04:38 +0000
Subject: [PATCH 2/3] feat: let a new account actually do something
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fresh login was an empty app with no way to fill it. The cause was not a
missing screen: registration created a user, organisation and workspace, but
no offering and no campaign — and a campaign requires an offering, a prospect
requires a campaign. There was no route that could add a person at all. The
pipeline had only ever been driven from a local script.
- Registration now provisions a starter offering and campaign. Existing
accounts are backfilled on first use rather than told to create something
the UI does not expose.
- POST /api/v1/prospects runs the full chain for a GitHub handle. It is
synchronous because a first-run user needs to see something appear, and it
reports identities linked and signals found rather than "queued". A handle
that resolves to nothing answers 200 with a reason: a typo is information,
not a server fault.
- GET /api/v1/people backs a real prospect list and detail page, so the
evidence behind a recommendation is inspectable.
- Today's empty state now distinguishes "no prospects yet" from "no
recommendations yet" and offers the action that fixes the first.
The pipeline moves from apps/worker to packages/pipeline. Both the API and
the background loop run it, and an app importing another app's source made
the dependency direction a lie. apps/worker held nothing else, so it is gone.
Email verification ships alongside it. Format was already checked; what was
missing was any proof the address exists. Tokens are stored as a SHA-256
digest like sessions, superseded rather than accumulated on resend, and every
failure answers identically so a guessed token learns nothing. The gate is on
sending, not on signing in — research and drafting still work while the mail
is in flight. Accounts predating this are grandfathered, and a send failure
never fails the signup that triggered it.
Resend is reached over plain HTTP; with no key the link is logged instead, so
a fresh checkout still completes a signup.
395 tests.
Co-Authored-By: Claude Opus 5
---
.env.example | 12 +
README.md | 13 +-
apps/api/package.json | 2 +
apps/api/src/app.test.ts | 384 ++++++++++++++++++
apps/api/src/app.ts | 272 ++++++++++++-
apps/api/src/auth.ts | 133 +++++-
apps/api/src/test-seed.ts | 16 +-
apps/server/package.json | 2 +
apps/server/src/index.ts | 22 +-
apps/web/app/prospects/[id]/page.tsx | 122 ++++++
apps/web/app/prospects/page.tsx | 89 +++-
apps/web/app/today/page.tsx | 31 +-
apps/web/app/verify/page.tsx | 27 ++
apps/web/components/add-prospect.tsx | 113 ++++++
apps/web/components/verify-banner.tsx | 56 +++
apps/web/components/verify-form.tsx | 96 +++++
apps/web/lib/api.ts | 20 +-
apps/web/lib/types.ts | 36 ++
bun.lock | 43 +-
docs/prd-implementation-map.md | 28 +-
migrations/0006_email_verification.sql | 30 ++
packages/contracts/src/index.ts | 20 +
packages/email/package.json | 14 +
packages/email/src/index.ts | 11 +
packages/email/src/mailer.test.ts | 103 +++++
packages/email/src/mailer.ts | 89 ++++
packages/email/src/templates.ts | 41 ++
.../worker => packages/pipeline}/package.json | 15 +-
packages/pipeline/src/index.ts | 19 +
.../pipeline}/src/jobs.test.ts | 2 +-
.../worker => packages/pipeline}/src/jobs.ts | 0
.../pipeline}/src/pipeline.test.ts | 2 +-
.../pipeline}/src/pipeline.ts | 0
33 files changed, 1795 insertions(+), 68 deletions(-)
create mode 100644 apps/web/app/prospects/[id]/page.tsx
create mode 100644 apps/web/app/verify/page.tsx
create mode 100644 apps/web/components/add-prospect.tsx
create mode 100644 apps/web/components/verify-banner.tsx
create mode 100644 apps/web/components/verify-form.tsx
create mode 100644 migrations/0006_email_verification.sql
create mode 100644 packages/email/package.json
create mode 100644 packages/email/src/index.ts
create mode 100644 packages/email/src/mailer.test.ts
create mode 100644 packages/email/src/mailer.ts
create mode 100644 packages/email/src/templates.ts
rename {apps/worker => packages/pipeline}/package.json (59%)
create mode 100644 packages/pipeline/src/index.ts
rename {apps/worker => packages/pipeline}/src/jobs.test.ts (98%)
rename {apps/worker => packages/pipeline}/src/jobs.ts (100%)
rename {apps/worker => packages/pipeline}/src/pipeline.test.ts (99%)
rename {apps/worker => packages/pipeline}/src/pipeline.ts (100%)
diff --git a/.env.example b/.env.example
index cb06652..20f7686 100644
--- a/.env.example
+++ b/.env.example
@@ -47,6 +47,18 @@ ANTHROPIC_API_KEY=
# Overrides the default model (claude-opus-5).
ANTHROPIC_MODEL=
+# -------------------------------------------------------------------- email
+# Account email only — verification links. Outreach never goes through this.
+#
+# Optional. Unset, verification links are written to the container log instead
+# of being sent, so a fresh checkout can still complete a signup. Sending
+# requires BOTH values; EMAIL_FROM must be on a domain verified with Resend.
+RESEND_API_KEY=
+EMAIL_FROM=
+# Public origin used to build links that land in someone's inbox. Without it
+# links point at localhost, which is correct locally and wrong in production.
+APP_URL=
+
# -------------------------------------------------------------------- queue
# Optional until queued jobs need durability.
REDIS_URL=
diff --git a/README.md b/README.md
index 10e5fb7..eab0961 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ what exists.
```bash
bun install
bun run db:migrate # applies migrations to ./local.db
-bun test # 265 tests
+bun test # 395 tests
bun run check # format, typecheck, test
```
@@ -41,9 +41,11 @@ fixture provider, so a fresh checkout works with an empty `.env`.
apps/
api/ Hono service on /api/v1
web/ Next.js 16 mobile-first PWA
- worker/ background jobs: signal expiry, rescoring, privacy work
+ server/ the single entrypoint: API, PWA and the background loop
packages/
ai/ the only package that talks to a model: composer + quality gates
+ pipeline/ the discovery-to-queue chain and its background jobs
+ email/ account email only — verification links, never outreach
domain/ canonical types — depends on nothing
db/ Turso/libSQL client and migration runner
policy/ the deterministic policy engine
@@ -57,6 +59,10 @@ migrations/ forward-only .sql, applied in filename order
docker/ one Dockerfile per deployable service
```
+The pipeline lives in a package rather than an app because both the API
+(adding a prospect on demand) and the background loop run the same chain — an
+app importing another app's source would make the dependency direction a lie.
+
## The pipeline
One GitHub handle goes all the way to a card in the approval queue:
@@ -65,7 +71,8 @@ One GitHub handle goes all the way to a card in the approval queue:
enrich → resolve identities → collect signals → score → recommend
```
-`apps/worker/src/pipeline.ts` runs it. GitHub first because it is free, its
+`packages/pipeline/src/pipeline.ts` runs it, and `POST /api/v1/prospects` is
+how a person starts it from the UI. GitHub first because it is free, its
profiles carry links the person published themselves — `twitter_username`,
`blog`, `company` — and developer tooling is the launch wedge. A real profile
typically yields three linked identities before any paid provider is touched.
diff --git a/apps/api/package.json b/apps/api/package.json
index 61e4dff..37efb75 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -14,6 +14,8 @@
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/email": "workspace:*",
+ "@outreachgraph/pipeline": "workspace:*",
"@outreachgraph/policy": "workspace:*",
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/scoring": "workspace:*",
diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts
index 6f0c21d..8773e5b 100644
--- a/apps/api/src/app.test.ts
+++ b/apps/api/src/app.test.ts
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
import { StubModel } from '@outreachgraph/ai';
+import { GitHubProvider } from '@outreachgraph/providers';
+import type { Mailer, Message } from '@outreachgraph/email';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
@@ -585,3 +587,385 @@ describe('drafting on demand (PRD §14)', () => {
expect(rows.rows).toHaveLength(1);
});
});
+
+// ---------------------------------------------------------------- prospects
+
+/** A GitHub profile carrying the self-declared cross-links the resolver uses. */
+const GH_PROFILE = {
+ login: 'alexchen',
+ id: 4242,
+ name: 'Alex Chen',
+ company: '@Loopwright',
+ blog: 'https://loopwright.io',
+ location: 'Berlin',
+ email: null,
+ bio: 'Agent reliability',
+ twitter_username: 'alexbuilds',
+ public_repos: 30,
+ followers: 500,
+ html_url: 'https://github.com/alexchen',
+ created_at: '2015-01-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+};
+
+const GH_EVENTS = [
+ {
+ id: '1',
+ type: 'IssuesEvent',
+ created_at: new Date(Date.now() - 3 * 3_600_000).toISOString(),
+ repo: { id: 9, name: 'loopwright/agents', url: '' },
+ payload: {
+ action: 'opened',
+ issue: {
+ title: 'Anyone know a good alternative to Stripe for cross-border payouts?',
+ body: 'Fees are brutal.',
+ html_url: 'https://github.com/loopwright/agents/issues/12',
+ },
+ },
+ },
+];
+
+function stubGitHub(): GitHubProvider {
+ const fetchImpl = (async (input: string | URL | Request) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ const body = url.includes('/events/public')
+ ? GH_EVENTS
+ : url.includes('/repos')
+ ? []
+ : url.includes('/users/alexchen')
+ ? GH_PROFILE
+ : null;
+
+ if (!body) return new Response('{"message":"Not Found"}', { status: 404 });
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }) as unknown as typeof fetch;
+
+ return new GitHubProvider({ fetchImpl });
+}
+
+async function withGitHub(label: string): Promise> {
+ const seeded = await seedDatabase(label);
+ active = seeded;
+ return createApp({
+ db: seeded.db,
+ authenticate: async () => ACTOR,
+ github: stubGitHub(),
+ });
+}
+
+describe('adding a prospect (PRD §8)', () => {
+ test('a GitHub handle walks the chain and lands in the workspace', async () => {
+ const app = await withGitHub('prospect-add');
+ const response = await post(app, '/prospects', { handle: 'alexchen' });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.added).toBe(true);
+ expect(body.personId).toBeTruthy();
+ expect(body.stage).not.toBe('stopped');
+ });
+
+ test('a pasted profile URL means the same thing as the handle', async () => {
+ const app = await withGitHub('prospect-url');
+ const response = await post(app, '/prospects', { handle: 'https://github.com/alexchen' });
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).added).toBe(true);
+ });
+
+ test('an @-prefixed handle is accepted rather than rejected as invalid', async () => {
+ const app = await withGitHub('prospect-at');
+ expect((await post(app, '/prospects', { handle: '@alexchen' })).status).toBe(200);
+ });
+
+ test('a handle GitHub could never issue is refused before spending a call', async () => {
+ const app = await withGitHub('prospect-bad');
+ expect((await post(app, '/prospects', { handle: 'not a username!' })).status).toBe(400);
+ });
+
+ test('an unknown profile reports why rather than failing the request', async () => {
+ const app = await withGitHub('prospect-missing');
+ const response = await post(app, '/prospects', { handle: 'ghostuser' });
+
+ // A typo is information, not a server fault: 200 with a reason keeps the
+ // client from showing "something went wrong".
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.added).toBe(false);
+ expect(body.reason).toContain('ghostuser');
+ });
+
+ test('adding a prospect is audited', async () => {
+ const app = await withGitHub('prospect-audit');
+ await post(app, '/prospects', { handle: 'alexchen' });
+
+ const rows = await active!.db.execute(
+ "SELECT count(*) AS n FROM audit_events WHERE event_type = 'prospect.added'",
+ );
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('a viewer cannot add prospects', async () => {
+ const seeded = await seedDatabase('prospect-viewer');
+ active = seeded;
+ const app = createApp({
+ db: seeded.db,
+ authenticate: async () => ({ ...ACTOR, role: 'viewer' }),
+ github: stubGitHub(),
+ });
+
+ expect((await post(app, '/prospects', { handle: 'alexchen' })).status).toBe(403);
+ });
+
+ test('a workspace with no campaign gets one rather than an error', async () => {
+ const app = await withGitHub('prospect-no-campaign');
+ // Accounts created before registration provisioned a campaign have none.
+ await active!.db.execute('DELETE FROM recommendations');
+ await active!.db.execute('DELETE FROM campaign_people');
+ await active!.db.execute('DELETE FROM campaigns');
+
+ const response = await post(app, '/prospects', { handle: 'alexchen' });
+ expect(response.status).toBe(200);
+
+ const rows = await active!.db.execute('SELECT count(*) AS n FROM campaigns');
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+});
+
+// ------------------------------------------------------------ verification
+
+/** Collects sent messages instead of delivering them. */
+function recordingMailer(): { sent: Message[]; mailer: Mailer } {
+ const sent: Message[] = [];
+ return { sent, mailer: { send: async (message) => void sent.push(message) } };
+}
+
+describe('email verification', () => {
+ test('registering mails a verification link', async () => {
+ const seeded = await seedDatabase('verify-register');
+ active = seeded;
+ const { sent, mailer } = recordingMailer();
+ const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' });
+
+ const response = await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ expect(response.status).toBe(201);
+ expect(sent).toHaveLength(1);
+ expect(sent[0]!.to).toBe('new@example.com');
+ expect(sent[0]!.text).toContain('https://og.test/verify?token=');
+ });
+
+ test('a new account is unverified until the link is followed', async () => {
+ const seeded = await seedDatabase('verify-unverified');
+ active = seeded;
+ const { mailer } = recordingMailer();
+ const app = createApp({ db: seeded.db, mailer });
+
+ await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ const row = await seeded.db.execute({
+ sql: 'SELECT email_verified_at FROM users WHERE email = ?',
+ args: ['new@example.com'],
+ });
+ expect(row.rows[0]?.email_verified_at).toBeNull();
+ });
+
+ test('following the link confirms the address', async () => {
+ const seeded = await seedDatabase('verify-confirm');
+ active = seeded;
+ const { sent, mailer } = recordingMailer();
+ const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' });
+
+ await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1];
+ const response = await post(app, '/auth/verify', { token });
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).verified).toBe(true);
+ });
+
+ test('a token cannot be used twice', async () => {
+ const seeded = await seedDatabase('verify-replay');
+ active = seeded;
+ const { sent, mailer } = recordingMailer();
+ const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' });
+
+ await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1];
+ await post(app, '/auth/verify', { token });
+
+ expect((await post(app, '/auth/verify', { token })).status).toBe(400);
+ });
+
+ test('an expired token is refused', async () => {
+ const seeded = await seedDatabase('verify-expired');
+ active = seeded;
+ const { sent, mailer } = recordingMailer();
+ const app = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' });
+
+ await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ await seeded.db.execute({
+ sql: 'UPDATE email_verification_tokens SET expires_at = ?',
+ args: ['2000-01-01T00:00:00.000Z'],
+ });
+
+ const token = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1];
+ expect((await post(app, '/auth/verify', { token })).status).toBe(400);
+ });
+
+ test('a made-up token is refused', async () => {
+ const seeded = await seedDatabase('verify-forged');
+ active = seeded;
+ const app = createApp({ db: seeded.db });
+
+ expect((await post(app, '/auth/verify', { token: 'deadbeef' })).status).toBe(400);
+ });
+
+ test('resending supersedes the previous link rather than stacking', async () => {
+ const seeded = await seedDatabase('verify-resend');
+ active = seeded;
+ const { sent, mailer } = recordingMailer();
+
+ let userId = '';
+ const app = createApp({
+ db: seeded.db,
+ mailer,
+ appUrl: 'https://og.test',
+ authenticate: async () => (userId ? { ...ACTOR, userId } : undefined),
+ });
+
+ // Register through a second app instance so the guard above stays off
+ // until the account exists.
+ const open = createApp({ db: seeded.db, mailer, appUrl: 'https://og.test' });
+ const registered = await (
+ await post(open, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ })
+ ).json();
+ userId = registered.userId;
+
+ await post(app, '/auth/verify/resend');
+
+ const first = sent[0]!.text.match(/token=([a-f0-9]+)/)?.[1];
+ const second = sent[1]!.text.match(/token=([a-f0-9]+)/)?.[1];
+ expect(second).not.toBe(first);
+
+ // The old link must stop working, or "resend" becomes a way to hold
+ // several simultaneously valid tokens.
+ expect((await post(open, '/auth/verify', { token: first })).status).toBe(400);
+ expect((await post(open, '/auth/verify', { token: second })).status).toBe(200);
+ });
+
+ test('an unverified account cannot approve outreach', async () => {
+ const seeded = await seedDatabase('verify-gate');
+ active = seeded;
+ await seeded.db.execute({
+ sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?',
+ args: [SEED.userId],
+ });
+
+ const app = createApp({ db: seeded.db, authenticate: async () => ACTOR });
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/approve`, {});
+
+ expect(response.status).toBe(403);
+ expect((await response.json()).error.code).toBe('email_unverified');
+ });
+
+ test('an unverified account can still add prospects and read evidence', async () => {
+ const seeded = await seedDatabase('verify-gate-read');
+ active = seeded;
+ await seeded.db.execute({
+ sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?',
+ args: [SEED.userId],
+ });
+
+ const app = createApp({
+ db: seeded.db,
+ authenticate: async () => ACTOR,
+ github: stubGitHub(),
+ });
+
+ // The gate is on sending, not on looking around.
+ expect((await get(app, '/people')).status).toBe(200);
+ expect((await post(app, '/prospects', { handle: 'alexchen' })).status).toBe(200);
+ });
+
+ test('a failed send does not fail the signup', async () => {
+ const seeded = await seedDatabase('verify-send-fails');
+ active = seeded;
+ const app = createApp({
+ db: seeded.db,
+ mailer: {
+ send: async () => {
+ throw new Error('resend is down');
+ },
+ },
+ });
+
+ const response = await post(app, '/auth/register', {
+ email: 'new@example.com',
+ password: 'correct horse battery',
+ });
+
+ // Losing the account over a transient provider outage is worse than an
+ // account whose owner has to press "resend".
+ expect(response.status).toBe(201);
+
+ const rows = await seeded.db.execute(
+ "SELECT count(*) AS n FROM audit_events WHERE event_type = 'email.send_failed'",
+ );
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('/auth/me reports verification so the UI can warn before a refusal', async () => {
+ const { app } = await harness('verify-me');
+ const body = await (await get(app, '/auth/me')).json();
+
+ expect(body.emailVerified).toBe(true);
+ });
+});
+
+describe('listing prospects', () => {
+ test('the seeded prospect is listed with its score', async () => {
+ const { app } = await harness('people-list');
+ const response = await get(app, '/people');
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.people.length).toBeGreaterThan(0);
+ expect(body.people[0].display_name).toBeTruthy();
+ });
+
+ test('a deleted person is not listed', async () => {
+ const { app } = await harness('people-list-deleted');
+ await active!.db.execute({
+ sql: "UPDATE people SET status = 'deleted' WHERE id = ?",
+ args: [SEED.personId],
+ });
+
+ const body = await (await get(app, '/people')).json();
+ expect(body.people).toHaveLength(0);
+ });
+});
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index dff5e6e..5a15866 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -11,6 +11,7 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { z } from 'zod';
import {
+ addProspectSchema,
approveRecommendationSchema,
createSuppressionSchema,
executeActionSchema,
@@ -24,16 +25,22 @@ import { now, queryOne, type Client } from '@outreachgraph/db';
import {
actorFromSession,
clearedCookie,
+ isEmailVerified,
login,
logout,
+ mintVerificationToken,
readCookie,
registerUser,
SESSION_COOKIE,
sessionCookie,
+ verifyEmailToken,
workspacesForUser,
} from './auth';
import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy';
import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
+import { runPipeline } from '@outreachgraph/pipeline';
+import { GitHubProvider } from '@outreachgraph/providers';
+import { ConsoleMailer, verificationEmail, type Mailer } from '@outreachgraph/email';
import { ApiError, canApprove, type AppEnv, type RequestActor } from './context';
import * as repo from './repository';
@@ -56,6 +63,18 @@ export interface AppOptions {
* other route works unchanged and drafting returns 503.
*/
readonly model?: TextModel;
+ /**
+ * Supplies GitHub enrichment and activity when adding a prospect. Tests
+ * inject a fake; production leaves it unset and gets the real client.
+ */
+ readonly github?: GitHubProvider;
+ /**
+ * Sends account email. Omit to log messages instead of sending them, which
+ * is what local development and the test suite do.
+ */
+ readonly mailer?: Mailer;
+ /** Public origin, used to build links that land in someone's inbox. */
+ readonly appUrl?: string;
readonly version?: string;
readonly commitHash?: string;
}
@@ -154,6 +173,31 @@ export function createApp(options: AppOptions): Hono {
return undefined;
};
+ /**
+ * Mints a token and mails the link.
+ *
+ * A send failure never fails the request that triggered it: an account that
+ * exists and can sign in, with a resend button one tap away, beats a 500
+ * that loses the password the user just chose. The failure is audited so it
+ * is visible rather than silent.
+ */
+ const sendVerification = async (userId: string, email: string): Promise => {
+ const minted = await mintVerificationToken(options.db, userId, email);
+ const link = `${options.appUrl ?? 'http://localhost:8080'}/verify?token=${minted.token}`;
+
+ try {
+ await (options.mailer ?? new ConsoleMailer()).send(verificationEmail(email, link));
+ } catch (error) {
+ await repo.audit(options.db, {
+ actorKind: 'system',
+ eventType: 'email.send_failed',
+ entityKind: 'user',
+ entityId: userId,
+ detail: { kind: 'verification', message: String(error) },
+ });
+ }
+ };
+
const api = new Hono();
// ------------------------------------------------------------------- auth
@@ -165,13 +209,44 @@ export function createApp(options: AppOptions): Hono {
const result = await registerUser(options.db, body);
// Registering logs you straight in; a signup that then demands a login is
- // just a worse signup.
+ // just a worse signup. Verification gates outbound actions, not access —
+ // someone should be able to look around while the mail is in flight.
const session = await login(options.db, body.email, body.password, c.req.header('user-agent'));
+ await sendVerification(result.userId, body.email);
c.header('set-cookie', sessionCookie(session.token, session.expiresAt, secure));
return c.json({ userId: result.userId, workspaceId: result.workspaceId }, 201);
});
+ /**
+ * Confirms an address from the emailed link.
+ *
+ * Unauthenticated on purpose: the link is often opened in a different
+ * browser from the one that signed up, and requiring a session there would
+ * strand people on a login screen holding a valid token.
+ */
+ auth.post('/verify', async (c) => {
+ const body = safeJson(await c.req.raw.text());
+ const result = await verifyEmailToken(options.db, String(body.token ?? ''));
+ return c.json({ verified: true, email: result.email });
+ });
+
+ auth.post('/verify/resend', async (c) => {
+ const actor = await resolveActor(c.req.raw);
+ if (!actor) throw ApiError.unauthorized();
+
+ const user = await queryOne<{ email: string; email_verified_at: string | null }>(
+ options.db,
+ 'SELECT email, email_verified_at FROM users WHERE id = ?',
+ [actor.userId],
+ );
+ if (!user) throw ApiError.unauthorized();
+ if (user.email_verified_at) return c.json({ sent: false, reason: 'already_verified' });
+
+ await sendVerification(actor.userId, user.email);
+ return c.json({ sent: true });
+ });
+
auth.post('/login', async (c) => {
const body = await parseBody(c.req.raw, loginSchema);
const session = await login(options.db, body.email, body.password, c.req.header('user-agent'));
@@ -192,14 +267,18 @@ export function createApp(options: AppOptions): Hono {
const actor = await resolveActor(c.req.raw);
if (!actor) throw ApiError.unauthorized();
- const user = await queryOne<{ id: string; email: string; name: string | null }>(
- options.db,
- 'SELECT id, email, name FROM users WHERE id = ?',
- [actor.userId],
- );
+ const user = await queryOne<{
+ id: string;
+ email: string;
+ name: string | null;
+ email_verified_at: string | null;
+ }>(options.db, 'SELECT id, email, name, email_verified_at FROM users WHERE id = ?', [
+ actor.userId,
+ ]);
return c.json({
user: user ?? { id: actor.userId, email: null, name: null },
+ emailVerified: Boolean(user?.email_verified_at),
workspaceId: actor.workspaceId,
organizationId: actor.organizationId,
role: actor.role,
@@ -250,7 +329,101 @@ export function createApp(options: AppOptions): Hono {
return c.json({ recommendations: rows.rows });
});
+ // ----------------------------------------------------------- prospects
+ /**
+ * Adds one prospect by GitHub handle and runs the full chain (PRD §8).
+ *
+ * Synchronous on purpose: a first-run user needs to see something appear,
+ * and a background job that silently produces nothing is exactly the empty
+ * app this replaces. The chain is a handful of GitHub calls, so it returns
+ * in seconds. When that stops being true, this becomes a queued job and the
+ * response becomes a job id — the route shape already allows it.
+ */
+ api.post('/prospects', async (c) => {
+ const actor = c.get('actor');
+ const db = c.get('db');
+
+ if (!canApprove(actor)) throw ApiError.forbidden('adding prospects');
+
+ const raw = safeJson(await c.req.raw.text());
+ const parsed = addProspectSchema.safeParse({ handle: normalizeHandle(raw.handle) });
+ if (!parsed.success) {
+ throw ApiError.badRequest(
+ 'enter a GitHub username or profile URL',
+ parsed.error.flatten().fieldErrors,
+ );
+ }
+
+ const handle = parsed.data.handle;
+ const campaignId = await ensureDefaultCampaign(db, actor.workspaceId);
+
+ const result = await runPipeline(
+ {
+ db,
+ workspaceId: actor.workspaceId,
+ campaignId,
+ providers: [],
+ github: options.github ?? new GitHubProvider(),
+ ...(options.model ? { model: options.model } : {}),
+ },
+ handle,
+ );
+
+ await repo.audit(db, {
+ workspaceId: actor.workspaceId,
+ actorKind: 'user',
+ actorId: actor.userId,
+ eventType: 'prospect.added',
+ entityKind: 'person',
+ entityId: result.personId ?? handle,
+ detail: { handle, stage: result.stage, stoppedBecause: result.stoppedBecause },
+ });
+
+ // A stopped chain is a real answer, not an error: "no GitHub profile for
+ // that handle" is information the user needs, and 200 with a reason keeps
+ // the client from treating a typo as a server fault.
+ return c.json({
+ added: result.stage !== 'stopped',
+ personId: result.personId,
+ stage: result.stage,
+ identitiesLinked: result.identitiesLinked,
+ signalsStored: result.signalsStored,
+ recommendationId: result.recommendationId,
+ ...(result.stoppedBecause ? { reason: result.stoppedBecause } : {}),
+ });
+ });
+
// -------------------------------------------------------------- people
+ /**
+ * The prospect list, ranked the way the UI ranks everything else — by
+ * opportunity, so the top of the list is the top of the queue.
+ */
+ api.get('/people', async (c) => {
+ const actor = c.get('actor');
+ const limit = clampLimit(c.req.query('limit'));
+
+ const rows = await c.get('db').execute({
+ sql: `SELECT p.id, p.display_name, p.current_title, p.identity_confidence, p.status,
+ co.name AS current_company,
+ cp.status AS prospect_status, cp.interaction_state,
+ s.opportunity, s.icp_fit, s.intent, s.reachability,
+ (SELECT COUNT(*) FROM signals g
+ WHERE g.person_id = p.id AND g.workspace_id = cp.workspace_id)
+ AS signal_count
+ FROM campaign_people cp
+ JOIN people p ON p.id = cp.person_id
+ LEFT JOIN companies co ON co.id = p.current_company_id
+ LEFT JOIN scores s
+ ON s.person_id = cp.person_id AND s.campaign_id = cp.campaign_id
+ WHERE cp.workspace_id = ? AND p.status != 'deleted'
+ ORDER BY COALESCE(s.opportunity, -1) DESC, p.display_name ASC
+ LIMIT ?`,
+ args: [actor.workspaceId, limit],
+ });
+
+ return c.json({ people: rows.rows });
+ });
+
api.get('/people/:id', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
@@ -362,6 +535,7 @@ export function createApp(options: AppOptions): Hono {
const db = c.get('db');
if (!canApprove(actor)) throw ApiError.forbidden('this role cannot approve outbound actions');
+ await requireVerifiedEmail(db, actor);
const body = await parseBody(c.req.raw, approveRecommendationSchema);
const recommendation = await repo.getRecommendation(db, actor.workspaceId, c.req.param('id'));
@@ -583,6 +757,8 @@ export function createApp(options: AppOptions): Hono {
const db = c.get('db');
const body = await parseBody(c.req.raw, executeActionSchema);
+ await requireVerifiedEmail(db, actor);
+
const action = await repo.getAction(db, actor.workspaceId, c.req.param('id'));
if (!action) throw ApiError.notFound('action');
if (action.status === 'completed') throw ApiError.badRequest('action is already completed');
@@ -845,6 +1021,90 @@ function numberOr(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
+/**
+ * Refuses outbound work from an unconfirmed address.
+ *
+ * The gate is on sending, not on signing in: someone should be able to add
+ * prospects and read evidence while the mail is in flight. It sits here
+ * rather than in the policy engine deliberately — the engine decides what a
+ * network permits, and account state is not a policy question.
+ *
+ * A service token has no user, so this cannot apply to it; machine callers
+ * are authorised by holding the token.
+ */
+async function requireVerifiedEmail(db: Client, actor: RequestActor): Promise {
+ if (actor.userId === 'usr_service') return;
+ if (await isEmailVerified(db, actor.userId)) return;
+
+ throw new ApiError(403, 'email_unverified', 'confirm your email address before sending anything');
+}
+
+/**
+ * Accepts what people actually paste.
+ *
+ * A profile URL, an `@handle` and a bare username all mean the same thing to
+ * the person typing, so all three are normalised to the handle rather than
+ * rejected as invalid input.
+ */
+function normalizeHandle(value: unknown): string {
+ if (typeof value !== 'string') return '';
+
+ const trimmed = value.trim();
+ const url = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/?#]+)/i);
+ return (url?.[1] ?? trimmed).replace(/^@/, '').replace(/\/+$/, '');
+}
+
+/**
+ * Returns the workspace's campaign, provisioning one if it has none.
+ *
+ * Registration now creates an offering and campaign up front, but accounts
+ * made before that have neither, and an existing user hitting "add prospect"
+ * must not get an error telling them to create something the UI does not yet
+ * expose. Backfilling here keeps the failure mode out of the product.
+ */
+async function ensureDefaultCampaign(db: Client, workspaceId: string): Promise {
+ const existing = await queryOne<{ id: string }>(
+ db,
+ `SELECT id FROM campaigns WHERE workspace_id = ?
+ ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, created_at ASC LIMIT 1`,
+ [workspaceId],
+ );
+ if (existing) return existing.id;
+
+ const stamp = now();
+ const offering = await queryOne<{ id: string }>(
+ db,
+ 'SELECT id FROM offerings WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1',
+ [workspaceId],
+ );
+
+ let offeringId = offering?.id;
+ if (!offeringId) {
+ offeringId = newId('offering');
+ await db.execute({
+ sql: `INSERT INTO offerings (id, workspace_id, name, category, description, created_at, updated_at)
+ VALUES (?, ?, 'Your offering', 'unspecified', ?, ?, ?)`,
+ args: [
+ offeringId,
+ workspaceId,
+ 'Describe what you sell here. Every draft is grounded in this text.',
+ stamp,
+ stamp,
+ ],
+ });
+ }
+
+ const campaignId = newId('campaign');
+ await db.execute({
+ sql: `INSERT INTO campaigns (id, workspace_id, name, offering_id, approval_mode, status,
+ created_at, updated_at, started_at)
+ VALUES (?, ?, 'First campaign', ?, 'draft_and_approve', 'active', ?, ?, ?)`,
+ args: [campaignId, workspaceId, offeringId, stamp, stamp, stamp],
+ });
+
+ return campaignId;
+}
+
/**
* Constant-time string comparison, so a service token cannot be recovered by
* timing how long a rejection takes.
diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts
index f53da79..dd618cc 100644
--- a/apps/api/src/auth.ts
+++ b/apps/api/src/auth.ts
@@ -88,13 +88,20 @@ export interface RegisterResult {
readonly userId: string;
readonly organizationId: string;
readonly workspaceId: string;
+ readonly offeringId: string;
+ readonly campaignId: string;
}
/**
- * Creates a user with their own organization and first workspace.
+ * Creates a user with their own organization, workspace, offering and campaign.
*
* A user with no workspace cannot do anything in this product, so registration
- * provisions one rather than leaving a half-created account.
+ * provisions one rather than leaving a half-created account. The offering and
+ * campaign are provisioned for the same reason: a campaign requires an
+ * offering, and a prospect requires a campaign, so an account without both is
+ * one where "add a prospect" has nowhere to write. They are placeholders the
+ * user is expected to edit — the composer grounds every draft in the offering,
+ * so leaving it unedited produces weak drafts, not wrong ones.
*/
export async function registerUser(db: Client, input: RegisterInput): Promise {
const email = normalizeEmail(input.email);
@@ -111,6 +118,8 @@ export async function registerUser(db: Client, input: RegisterInput): Promise {
+ const token = mintSessionToken();
+ const stamp = now();
+ const expiresAt = new Date(
+ new Date(stamp).getTime() + VERIFICATION_TTL_HOURS * 3_600_000,
+ ).toISOString();
+
+ await db.batch([
+ { sql: 'DELETE FROM email_verification_tokens WHERE user_id = ?', args: [userId] },
+ {
+ sql: `INSERT INTO email_verification_tokens (id, user_id, token_hash, email, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ args: [newId('session'), userId, await hashToken(token), email, stamp, expiresAt],
+ },
+ ]);
+
+ return { token, email, expiresAt };
+}
+
+export interface VerificationResult {
+ readonly userId: string;
+ readonly email: string;
+}
+
+/**
+ * Consumes a verification token and marks the address confirmed.
+ *
+ * Every failure — unknown, expired, already used — answers the same way. A
+ * verification endpoint that distinguishes them tells an attacker holding a
+ * guessed token whether it ever existed.
+ */
+export async function verifyEmailToken(db: Client, token: string): Promise {
+ const invalid = new ApiError(400, 'invalid_token', 'that link is invalid or has expired');
+ if (!token) throw invalid;
+
+ const row = await queryOne<{
+ id: string;
+ user_id: string;
+ email: string;
+ expires_at: string;
+ consumed_at: string | null;
+ }>(db, 'SELECT * FROM email_verification_tokens WHERE token_hash = ?', [await hashToken(token)]);
+
+ if (!row || row.consumed_at) throw invalid;
+
+ const stamp = now();
+ if (new Date(row.expires_at).getTime() <= new Date(stamp).getTime()) throw invalid;
+
+ // The address may have changed since the token was minted; verifying the
+ // old one would confirm something the user no longer uses.
+ const user = await queryOne<{ email: string }>(db, 'SELECT email FROM users WHERE id = ?', [
+ row.user_id,
+ ]);
+ if (!user || user.email !== row.email) throw invalid;
+
+ await db.batch([
+ {
+ sql: 'UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ?',
+ args: [stamp, row.id],
+ },
+ {
+ sql: 'UPDATE users SET email_verified_at = ?, updated_at = ? WHERE id = ?',
+ args: [stamp, stamp, row.user_id],
+ },
+ ]);
+
+ return { userId: row.user_id, email: row.email };
+}
+
+export async function isEmailVerified(db: Client, userId: string): Promise {
+ const row = await queryOne<{ email_verified_at: string | null }>(
+ db,
+ 'SELECT email_verified_at FROM users WHERE id = ?',
+ [userId],
+ );
+ return Boolean(row?.email_verified_at);
+}
+
/** Cookie attributes. `secure` is dropped only for plain-HTTP local dev. */
export function sessionCookie(token: string, expiresAt: string, secure: boolean): string {
const parts = [
diff --git a/apps/api/src/test-seed.ts b/apps/api/src/test-seed.ts
index f8292e6..2782453 100644
--- a/apps/api/src/test-seed.ts
+++ b/apps/api/src/test-seed.ts
@@ -45,9 +45,11 @@ export async function seedDatabase(label: string): Promise {
args: [SEED.organizationId, stamp, stamp],
},
{
- sql: `INSERT INTO users (id, email, name, created_at, updated_at)
- VALUES (?, 'test@example.com', 'Test User', ?, ?)`,
- args: [SEED.userId, stamp, stamp],
+ // Verified, because the fixture stands for an established account.
+ // Tests that care about the unverified path clear this explicitly.
+ sql: `INSERT INTO users (id, email, name, email_verified_at, created_at, updated_at)
+ VALUES (?, 'test@example.com', 'Test User', ?, ?, ?)`,
+ args: [SEED.userId, stamp, stamp, stamp],
},
{
sql: `INSERT INTO workspaces (id, organization_id, name, slug, min_outreach_confidence,
@@ -85,6 +87,14 @@ export async function seedDatabase(label: string): Promise {
'San Francisco Bay Area', 0.97, 'active', 1, 0, ?, ?)`,
args: [SEED.personId, SEED.companyId, stamp, stamp],
},
+ {
+ // The pipeline always writes this row, so a fixture without one is a
+ // workspace no real code path can produce.
+ sql: `INSERT INTO campaign_people (campaign_id, person_id, workspace_id, status,
+ interaction_state, discovered_at, updated_at)
+ VALUES (?, ?, ?, 'recommended', 'never_contacted', ?, ?)`,
+ args: [SEED.campaignId, SEED.personId, SEED.workspaceId, stamp, stamp],
+ },
{
sql: `INSERT INTO social_identities (id, person_id, network, handle, platform_user_id,
confidence, source_type, verified_by, first_seen_at)
diff --git a/apps/server/package.json b/apps/server/package.json
index 48d15bc..e4e8dc2 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -13,6 +13,8 @@
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/email": "workspace:*",
+ "@outreachgraph/pipeline": "workspace:*",
"@outreachgraph/policy": "workspace:*",
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/recommend": "workspace:*",
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 8dc38ec..4d4dc7c 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -21,9 +21,10 @@
import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db';
import { ClaudeModel } from '@outreachgraph/ai';
+import { ResendMailer } from '@outreachgraph/email';
import { createApp } from '../../api/src/app';
import { pruneSessions } from '../../api/src/auth';
-import { expireSignals, processDeletion } from '../../worker/src/jobs';
+import { expireSignals, processDeletion } from '@outreachgraph/pipeline';
const PORT = Number(process.env.PORT ?? 8080);
const WEB_PORT = Number(process.env.WEB_PORT ?? 3001);
@@ -78,10 +79,29 @@ const model = process.env.ANTHROPIC_API_KEY
if (!model) console.log('no ANTHROPIC_API_KEY: drafting disabled, queue still runs');
+/**
+ * Account email.
+ *
+ * Same shape as the model above: absent credentials degrade to logging rather
+ * than to a refusal to boot. A verification link printed in the container log
+ * is recoverable; a container that will not start is not.
+ */
+const mailer =
+ process.env.RESEND_API_KEY && process.env.EMAIL_FROM
+ ? new ResendMailer({
+ apiKey: process.env.RESEND_API_KEY,
+ from: process.env.EMAIL_FROM,
+ })
+ : undefined;
+
+if (!mailer) console.log('no RESEND_API_KEY/EMAIL_FROM: verification links are logged, not sent');
+
// ---------------------------------------------------------------------- api
const api = createApp({
db,
...(model ? { model } : {}),
+ ...(mailer ? { mailer } : {}),
+ ...(process.env.APP_URL ? { appUrl: process.env.APP_URL } : {}),
...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}),
// Cookies must not be Secure over plain HTTP, or local development can
// never hold a session.
diff --git a/apps/web/app/prospects/[id]/page.tsx b/apps/web/app/prospects/[id]/page.tsx
new file mode 100644
index 0000000..1077c77
--- /dev/null
+++ b/apps/web/app/prospects/[id]/page.tsx
@@ -0,0 +1,122 @@
+import Link from 'next/link';
+import { notFound, redirect } from 'next/navigation';
+import {
+ ApiUnavailableError,
+ NotAuthenticatedError,
+ fetchProspect,
+ relativeTime,
+} from '../../../lib/api';
+import type { ProspectDetail } from '../../../lib/types';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata = { title: 'Prospect · OutreachGraph' };
+
+/**
+ * One prospect: who we think they are, and what we can prove (PRD §25.3).
+ *
+ * Identities carry their confidence and signals carry their source link,
+ * because the product's claim is not "we found this person" but "here is why
+ * we believe it" — a reviewer who cannot check the evidence cannot approve
+ * anything responsibly.
+ */
+export default async function ProspectPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+
+ let detail: ProspectDetail;
+
+ try {
+ detail = await fetchProspect(id);
+ } catch (error) {
+ if (error instanceof NotAuthenticatedError) redirect('/login');
+ if (error instanceof ApiUnavailableError) {
+ return (
+
+ The API is not reachable right now.
+
+ );
+ }
+ notFound();
+ }
+
+ const { person, identities, signals } = detail;
+
+ return (
+
+
+ ← Prospects
+
+
+
+ {person.display_name}
+ {person.current_title ?? '—'}
+
+ Identity confidence {Math.round((person.identity_confidence ?? 0) * 100)}%
+
+
+
+
+
+ Identities
+
+ {identities.length ? (
+
+ {identities.map((identity) => (
+
+
+
{identity.handle}
+
{identity.network}
+
+
+ {identity.confidence.toFixed(2)}
+
+
+ ))}
+
+ ) : (
+ No linked identities.
+ )}
+
+
+
+
+ Signals
+
+ {signals.length ? (
+
+ ) : (
+
+ No signals captured yet. Nothing personalised can be written without them.
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/prospects/page.tsx b/apps/web/app/prospects/page.tsx
index dd691f5..319370f 100644
--- a/apps/web/app/prospects/page.tsx
+++ b/apps/web/app/prospects/page.tsx
@@ -1,17 +1,94 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+import { AddProspect } from '../../components/add-prospect';
+import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../lib/api';
+import type { ProspectRow } from '../../lib/types';
+
+export const dynamic = 'force-dynamic';
+
export const metadata = { title: 'Prospects · OutreachGraph' };
-export default function ProspectsPage() {
+/**
+ * Prospects — everyone in the workspace, ranked by opportunity (PRD §25.2).
+ *
+ * The add form sits above the list rather than behind a button because on a
+ * new account this page is the only thing standing between an empty product
+ * and a working one.
+ */
+export default async function ProspectsPage() {
+ let people: ProspectRow[] = [];
+ let offline = false;
+
+ try {
+ people = await fetchProspects();
+ } catch (error) {
+ if (error instanceof NotAuthenticatedError) redirect('/login');
+ if (error instanceof ApiUnavailableError) offline = true;
+ else throw error;
+ }
+
return (
-
- Prospect search is not built yet. The API exposes /api/v1/people/:id with
- identities, signals and provenance.
-
+
+
+ {people.length > 0 ? (
+
+ {people.map((person) => (
+
+ ))}
+
+ ) : !offline ? (
+
+ No prospects yet. Add a GitHub handle above and the pipeline will enrich, resolve, collect
+ signals and score them.
+
+ ) : null}
);
}
+
+function ProspectItem({ person }: { person: ProspectRow }) {
+ const subtitle = [person.current_title, person.current_company].filter(Boolean).join(' · ');
+
+ return (
+
+
+
+ {person.display_name}
+
+ {person.opportunity ?? '—'}
+
+
+
+ {subtitle || '—'}
+
+
+
+
Signals
+ {person.signal_count}
+
+
+
Identity
+
+ {Math.round((person.identity_confidence ?? 0) * 100)}%
+
+
+
+
Status
+ {person.prospect_status.replace(/_/g, ' ')}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/today/page.tsx b/apps/web/app/today/page.tsx
index 4310175..303f983 100644
--- a/apps/web/app/today/page.tsx
+++ b/apps/web/app/today/page.tsx
@@ -1,9 +1,11 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
+import { VerifyBanner } from '../../components/verify-banner';
import {
ApiUnavailableError,
NotAuthenticatedError,
fetchApprovals,
+ fetchMe,
fetchSignals,
relativeTime,
} from '../../lib/api';
@@ -21,10 +23,11 @@ export const metadata = { title: 'Today · OutreachGraph' };
export default async function TodayPage() {
let approvals: Awaited> = [];
let signals: Awaited> = [];
+ let me: Awaited> | undefined;
let offline = false;
try {
- [approvals, signals] = await Promise.all([fetchApprovals(), fetchSignals()]);
+ [approvals, signals, me] = await Promise.all([fetchApprovals(), fetchSignals(), fetchMe()]);
} catch (error) {
if (error instanceof NotAuthenticatedError) redirect('/login');
if (error instanceof ApiUnavailableError) offline = true;
@@ -35,6 +38,8 @@ export default async function TodayPage() {
return (
+ {me && !me.emailVerified ?
: null}
+
Today
@@ -81,10 +86,28 @@ export default async function TodayPage() {
) : null}
+ {/*
+ An empty queue has two very different causes, and telling them apart
+ is the difference between a working product and a broken-looking one.
+ With no prospects at all there is nothing for the pipeline to act on,
+ so the only useful thing to say is "add one" — the old copy promised
+ signals that could never arrive.
+ */}
{!offline && approvals.length === 0 ? (
-
- Nothing waiting. Recommendations appear as fresh signals arrive.
-
+
+
Nothing waiting.
+
+ {signals.length === 0
+ ? 'Add a prospect and the pipeline will research them, collect public signals and score the opportunity.'
+ : 'Signals are arriving; recommendations appear when one is worth acting on.'}
+
+
+ Add a prospect
+
+
) : null}
);
diff --git a/apps/web/app/verify/page.tsx b/apps/web/app/verify/page.tsx
new file mode 100644
index 0000000..93d795e
--- /dev/null
+++ b/apps/web/app/verify/page.tsx
@@ -0,0 +1,27 @@
+import { VerifyForm } from '../../components/verify-form';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata = { title: 'Confirm your email · OutreachGraph' };
+
+/**
+ * The landing page for the emailed verification link.
+ *
+ * The token arrives in the query string and is posted from the browser rather
+ * than consumed here, so a mail client or scanner that prefetches the link
+ * cannot silently burn a single-use token before the person ever clicks it.
+ */
+export default async function VerifyPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ token?: string }>;
+}) {
+ const { token } = await searchParams;
+
+ return (
+
+
Confirm your email
+
+
+ );
+}
diff --git a/apps/web/components/add-prospect.tsx b/apps/web/components/add-prospect.tsx
new file mode 100644
index 0000000..8fb506d
--- /dev/null
+++ b/apps/web/components/add-prospect.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, type FormEvent } from 'react';
+
+/**
+ * Add one prospect by GitHub handle (PRD §8).
+ *
+ * The chain runs synchronously, so this reports what actually happened rather
+ * than "queued": how many identities were linked and how many signals were
+ * found is the difference between a prospect worth pursuing and a dead end,
+ * and hiding it behind a spinner that resolves to nothing was the old
+ * behaviour of an empty app.
+ *
+ * A handle that resolves to no profile comes back 200 with a reason. That is
+ * a typo, not a server fault, and it is shown as a note rather than an error.
+ */
+export function AddProspect() {
+ const router = useRouter();
+ const [handle, setHandle] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState();
+ const [note, setNote] = useState();
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!handle.trim()) return;
+
+ setBusy(true);
+ setError(undefined);
+ setNote(undefined);
+
+ try {
+ const response = await fetch('/api/v1/prospects', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ handle }),
+ });
+
+ const payload = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ setError(payload?.error?.message ?? `that failed (${response.status})`);
+ return;
+ }
+
+ if (!payload.added) {
+ setNote(payload.reason ?? 'nothing to add for that handle');
+ return;
+ }
+
+ const found =
+ payload.signalsStored > 0
+ ? `${payload.signalsStored} signal${payload.signalsStored === 1 ? '' : 's'} found`
+ : 'no public signals yet';
+
+ setNote(`Added. ${payload.identitiesLinked} identities linked, ${found}.`);
+ setHandle('');
+ router.refresh();
+ } catch {
+ setError('could not reach the server');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/verify-banner.tsx b/apps/web/components/verify-banner.tsx
new file mode 100644
index 0000000..2256ab2
--- /dev/null
+++ b/apps/web/components/verify-banner.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useState } from 'react';
+
+/**
+ * Tells an unverified account why approving will fail, before they try.
+ *
+ * Discovering a gate by being refused mid-task is the worst way to learn
+ * about it, so this states the limit up front and offers the one action that
+ * clears it.
+ */
+export function VerifyBanner({ email }: { email: string | null }) {
+ const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'failed'>('idle');
+
+ async function resend() {
+ setState('sending');
+ try {
+ const response = await fetch('/api/v1/auth/verify/resend', {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ setState(response.ok ? 'sent' : 'failed');
+ } catch {
+ setState('failed');
+ }
+ }
+
+ return (
+
+
Confirm your email to send anything.
+
+ Research, signals and drafts all work now. Approving outreach needs a confirmed address
+ {email ? ` — we sent a link to ${email}` : ''}.
+
+
+ {state === 'sent' ? (
+
Sent. The newest link is the one that works.
+ ) : (
+
+ {state === 'sending' ? 'Sending…' : 'Resend the link'}
+
+ )}
+
+ {state === 'failed' ? (
+
+ Could not send it. Try again in a moment.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/components/verify-form.tsx b/apps/web/components/verify-form.tsx
new file mode 100644
index 0000000..0d5dbb5
--- /dev/null
+++ b/apps/web/components/verify-form.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+
+type State = 'idle' | 'working' | 'done' | 'failed';
+
+/**
+ * Confirms the address, then gets out of the way.
+ *
+ * The POST happens on mount rather than behind a button: the person already
+ * expressed intent by clicking the link in their inbox, and asking them to
+ * click a second time to do the thing they just asked for is friction with no
+ * security value. Prefetch protection comes from this being a POST at all.
+ */
+export function VerifyForm({ token }: { token: string }) {
+ const [state, setState] = useState('idle');
+ const [message, setMessage] = useState();
+
+ useEffect(() => {
+ if (!token) {
+ setState('failed');
+ setMessage('That link is missing its token. Try the most recent email.');
+ return;
+ }
+
+ let cancelled = false;
+ setState('working');
+
+ (async () => {
+ try {
+ const response = await fetch('/api/v1/auth/verify', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ token }),
+ });
+
+ if (cancelled) return;
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ setState('failed');
+ setMessage(body?.error?.message ?? 'that link is invalid or has expired');
+ return;
+ }
+
+ setState('done');
+ } catch {
+ if (!cancelled) {
+ setState('failed');
+ setMessage('could not reach the server');
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
+ if (state === 'done') {
+ return (
+
+
Confirmed.
+
+ Your address is verified and outreach is unlocked.
+
+
+ Go to Today
+
+
+ );
+ }
+
+ if (state === 'failed') {
+ return (
+
+
+ {message}
+
+
+ Sign in and use “Resend” to get a fresh link — each one supersedes the last.
+
+
+ Sign in
+
+
+ );
+ }
+
+ return Confirming…
;
+}
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 96a9773..8e873c1 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -9,9 +9,16 @@
*/
import { cookies } from 'next/headers';
-import type { ApprovalCard, CurrentUser, SignalRow } from './types';
+import type { ApprovalCard, CurrentUser, ProspectDetail, ProspectRow, SignalRow } from './types';
-export type { ApprovalCard, CurrentUser, SignalRow } from './types';
+export type {
+ ApprovalCard,
+ CurrentUser,
+ IdentityRow,
+ ProspectDetail,
+ ProspectRow,
+ SignalRow,
+} from './types';
export { relativeTime } from './format';
/**
@@ -94,3 +101,12 @@ export async function fetchSignals(): Promise {
const body = await request<{ signals: SignalRow[] }>('/signals?limit=50');
return body.signals;
}
+
+export async function fetchProspects(): Promise {
+ const body = await request<{ people: ProspectRow[] }>('/people?limit=100');
+ return body.people;
+}
+
+export async function fetchProspect(id: string): Promise {
+ return request(`/people/${encodeURIComponent(id)}`);
+}
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index a08c77c..5bb4d74 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -36,8 +36,44 @@ export interface SignalRow {
relevance: number;
}
+export interface ProspectRow {
+ id: string;
+ display_name: string;
+ current_title: string | null;
+ current_company: string | null;
+ identity_confidence: number;
+ prospect_status: string;
+ interaction_state: string;
+ opportunity: number | null;
+ icp_fit: number | null;
+ intent: number | null;
+ reachability: number | null;
+ signal_count: number;
+}
+
+export interface IdentityRow {
+ id: string;
+ network: string;
+ handle: string;
+ confidence: number;
+ source_type: string | null;
+}
+
+export interface ProspectDetail {
+ person: {
+ id: string;
+ display_name: string;
+ current_title: string | null;
+ identity_confidence: number;
+ status: string;
+ };
+ identities: IdentityRow[];
+ signals: SignalRow[];
+}
+
export interface CurrentUser {
user: { id: string; email: string | null; name: string | null };
+ emailVerified: boolean;
workspaceId: string;
role: string;
}
diff --git a/bun.lock b/bun.lock
index 2a4131b..d27f5f1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,6 +18,8 @@
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/email": "workspace:*",
+ "@outreachgraph/pipeline": "workspace:*",
"@outreachgraph/policy": "workspace:*",
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/scoring": "workspace:*",
@@ -34,6 +36,8 @@
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/email": "workspace:*",
+ "@outreachgraph/pipeline": "workspace:*",
"@outreachgraph/policy": "workspace:*",
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/recommend": "workspace:*",
@@ -61,21 +65,6 @@
"tailwindcss": "^4.0.0",
},
},
- "apps/worker": {
- "name": "@outreachgraph/worker",
- "version": "0.1.0",
- "dependencies": {
- "@outreachgraph/ai": "workspace:*",
- "@outreachgraph/db": "workspace:*",
- "@outreachgraph/domain": "workspace:*",
- "@outreachgraph/identity": "workspace:*",
- "@outreachgraph/policy": "workspace:*",
- "@outreachgraph/providers": "workspace:*",
- "@outreachgraph/recommend": "workspace:*",
- "@outreachgraph/scoring": "workspace:*",
- "@outreachgraph/signals": "workspace:*",
- },
- },
"packages/ai": {
"name": "@outreachgraph/ai",
"version": "0.1.0",
@@ -109,6 +98,10 @@
"name": "@outreachgraph/domain",
"version": "0.1.0",
},
+ "packages/email": {
+ "name": "@outreachgraph/email",
+ "version": "0.1.0",
+ },
"packages/identity": {
"name": "@outreachgraph/identity",
"version": "0.1.0",
@@ -116,6 +109,20 @@
"@outreachgraph/domain": "workspace:*",
},
},
+ "packages/pipeline": {
+ "name": "@outreachgraph/pipeline",
+ "version": "0.1.0",
+ "dependencies": {
+ "@outreachgraph/ai": "workspace:*",
+ "@outreachgraph/db": "workspace:*",
+ "@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/identity": "workspace:*",
+ "@outreachgraph/providers": "workspace:*",
+ "@outreachgraph/recommend": "workspace:*",
+ "@outreachgraph/scoring": "workspace:*",
+ "@outreachgraph/signals": "workspace:*",
+ },
+ },
"packages/policy": {
"name": "@outreachgraph/policy",
"version": "0.1.0",
@@ -287,8 +294,12 @@
"@outreachgraph/domain": ["@outreachgraph/domain@workspace:packages/domain"],
+ "@outreachgraph/email": ["@outreachgraph/email@workspace:packages/email"],
+
"@outreachgraph/identity": ["@outreachgraph/identity@workspace:packages/identity"],
+ "@outreachgraph/pipeline": ["@outreachgraph/pipeline@workspace:packages/pipeline"],
+
"@outreachgraph/policy": ["@outreachgraph/policy@workspace:packages/policy"],
"@outreachgraph/providers": ["@outreachgraph/providers@workspace:packages/providers"],
@@ -303,8 +314,6 @@
"@outreachgraph/web": ["@outreachgraph/web@workspace:apps/web"],
- "@outreachgraph/worker": ["@outreachgraph/worker@workspace:apps/worker"],
-
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md
index 8923cf8..3a50ff5 100644
--- a/docs/prd-implementation-map.md
+++ b/docs/prd-implementation-map.md
@@ -31,29 +31,31 @@ Where each part of the V1 PRD lives. Code comments cite section numbers
| §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` |
| §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — |
| §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests |
-| §17.6 Source deletion | `apps/worker/src/jobs.ts` `markSourceUnavailable` | worker tests |
+| §17.6 Source deletion | `packages/pipeline/src/jobs.ts` `markSourceUnavailable` | pipeline tests |
| §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests |
| §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests |
-| §21 Database model | `migrations/0000`–`0004` | `packages/db/src/migrate.test.ts` |
-| §23 API endpoints | `apps/api/src/app.ts` | 31 tests |
+| §21 Database model | `migrations/0000`–`0006` | `packages/db/src/migrate.test.ts` |
+| §23 API endpoints | `apps/api/src/app.ts` | 61 tests |
| §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` |
| §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests |
| §13 Next-best-action | `packages/recommend/src/engine.ts` | 25 tests |
| §20.6 Strategy agent | `packages/recommend` — deterministic, chooses only from `allowedActions` | included above |
| §16.6 GitHub as a signal source | `packages/providers/src/github/` | 27 tests |
-| §8 Pipeline, end to end | `apps/worker/src/pipeline.ts` | 11 tests + live GitHub run |
+| §8 Pipeline, end to end | `packages/pipeline/src/pipeline.ts`, `POST /prospects` | 11 + 8 tests, live GitHub run |
+| §25.2 Prospect list and detail | `apps/web/app/prospects/`, `GET /people` | 2 API tests + build |
+| §34 Email verification | `packages/email/`, `apps/api/src/auth.ts`, migration `0006` | 7 + 11 tests |
## Partially implemented
-| PRD section | State |
-| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| §7 Wizard | Domain types and contracts exist (`packages/domain/src/campaign.ts`, `packages/contracts`). No wizard UI or ICP agent. |
-| §12.5 Relationship score | Scoring function exists; nothing populates its inputs yet. |
-| §22 Person model | Schema complete. No ORM layer beyond `packages/db` helpers. |
-| §27 Conversations | Interaction states and rows exist; no inbound ingestion. |
-| §30 Billing | `usage_events` and `billing_accounts` tables; no metering enforcement or payment provider. |
-| §1.1 PWA | `apps/web` — installable manifest, service worker, offline fallback, update prompt, safe-area layout, bottom nav, approval card, signal feed. Prospects and campaign screens are placeholders. No push notifications; icons are SVG only, so raster icons are still needed for older Android. No Lighthouse gate in CI. |
-| §25.1–25.3 UI | Today, Signals and Approvals render live API data. The §25.2 prospect page is not built. |
+| PRD section | State |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| §7 Wizard | Domain types and contracts exist (`packages/domain/src/campaign.ts`, `packages/contracts`). No wizard UI or ICP agent. |
+| §12.5 Relationship score | Scoring function exists; nothing populates its inputs yet. |
+| §22 Person model | Schema complete. No ORM layer beyond `packages/db` helpers. |
+| §27 Conversations | Interaction states and rows exist; no inbound ingestion. |
+| §30 Billing | `usage_events` and `billing_accounts` tables; no metering enforcement or payment provider. |
+| §1.1 PWA | `apps/web` — installable manifest, service worker, offline fallback, update prompt, safe-area layout, bottom nav, approval card, signal feed, prospect list and detail, add-prospect flow, email verification. Campaign screens are still placeholders. No push notifications; icons are SVG only, so raster icons are still needed for older Android. No Lighthouse gate in CI. |
+| §25.1–25.3 UI | Today, Signals, Approvals, Prospects and prospect detail all render live API data. Offering and voice editing are not built, so drafts are grounded in a placeholder offering until a user edits it in the database. |
## Not started
diff --git a/migrations/0006_email_verification.sql b/migrations/0006_email_verification.sql
new file mode 100644
index 0000000..af72269
--- /dev/null
+++ b/migrations/0006_email_verification.sql
@@ -0,0 +1,30 @@
+-- Email verification (PRD §34 account security).
+--
+-- An unverified address is not just a typo risk: it is how someone signs up
+-- as a person who never consented, and how a bounced address quietly costs
+-- sender reputation. Outbound actions are gated on it, so verification is a
+-- product control rather than a formality.
+
+ALTER TABLE users ADD COLUMN email_verified_at TEXT;
+
+-- Accounts that existed before verification shipped are grandfathered. They
+-- were created by a human who was already using the product, and locking
+-- them out of their own workspace to prove an address they already receive
+-- mail at would be a regression, not a security gain.
+UPDATE users SET email_verified_at = created_at WHERE email_verified_at IS NULL;
+
+-- Tokens are stored as a SHA-256 digest for the same reason sessions are: a
+-- leaked database must not hand over a working verification link.
+CREATE TABLE email_verification_tokens (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ -- The address the token was minted for. Kept so a token issued before an
+ -- email change cannot verify the new address.
+ email TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ consumed_at TEXT
+);
+
+CREATE INDEX idx_email_verification_user ON email_verification_tokens(user_id);
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 536f08a..fd82d70 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -91,6 +91,26 @@ export const loginSchema = z.object({
export type RegisterInput = z.infer;
export type LoginInput = z.infer;
+/**
+ * Adding a prospect by GitHub handle.
+ *
+ * The pattern is GitHub's own rule — alphanumerics and single hyphens, never
+ * leading or trailing, 39 characters max — so a typo is rejected here rather
+ * than spending an API call to be told the profile does not exist. A pasted
+ * profile URL is a common enough input that the API strips it before
+ * validating; this schema sees only the handle.
+ */
+export const addProspectSchema = z.object({
+ handle: z
+ .string()
+ .trim()
+ .min(1)
+ .max(39)
+ .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/, 'not a valid GitHub username'),
+});
+
+export type AddProspectInput = z.infer;
+
export const createOfferingSchema = z.object({
name: z.string().min(1).max(200),
category: z.string().min(1).max(200),
diff --git a/packages/email/package.json b/packages/email/package.json
new file mode 100644
index 0000000..b89b128
--- /dev/null
+++ b/packages/email/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@outreachgraph/email",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {}
+}
diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts
new file mode 100644
index 0000000..2f414c7
--- /dev/null
+++ b/packages/email/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Transactional email.
+ *
+ * Only account mail goes through here — verification and password-adjacent
+ * notices. Outreach never does: that is the approval queue's job, and routing
+ * it through the same sender would make "we sent this on your behalf" and "we
+ * sent you a receipt" indistinguishable in a provider's logs.
+ */
+
+export { ConsoleMailer, ResendMailer, type Mailer, type Message } from './mailer';
+export { verificationEmail } from './templates';
diff --git a/packages/email/src/mailer.test.ts b/packages/email/src/mailer.test.ts
new file mode 100644
index 0000000..822069c
--- /dev/null
+++ b/packages/email/src/mailer.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, test } from 'bun:test';
+import { ConsoleMailer, MailerError, ResendMailer } from './mailer';
+import { verificationEmail } from './templates';
+
+function captureFetch(): { calls: RequestInit[]; impl: typeof fetch } {
+ const calls: RequestInit[] = [];
+ const impl = (async (_url: string, init: RequestInit) => {
+ calls.push(init);
+ return new Response('{"id":"1"}', { status: 200 });
+ }) as unknown as typeof fetch;
+
+ return { calls, impl };
+}
+
+describe('ResendMailer', () => {
+ test('sends the message with the configured sender', async () => {
+ const { calls, impl } = captureFetch();
+ const mailer = new ResendMailer({ apiKey: 'key', from: 'hi@og.com', fetchImpl: impl });
+
+ await mailer.send({ to: 'a@b.com', subject: 'Hi', text: 'body' });
+
+ const body = JSON.parse(String(calls[0]?.body));
+ expect(body.from).toBe('hi@og.com');
+ expect(body.to).toEqual(['a@b.com']);
+ expect(body.subject).toBe('Hi');
+ });
+
+ test('the API key travels as a bearer token, never in the body', async () => {
+ const { calls, impl } = captureFetch();
+ await new ResendMailer({ apiKey: 'secret', from: 'a@b.com', fetchImpl: impl }).send({
+ to: 'c@d.com',
+ subject: 'x',
+ text: 'y',
+ });
+
+ const headers = calls[0]?.headers as Record;
+ expect(headers.authorization).toBe('Bearer secret');
+ expect(String(calls[0]?.body)).not.toContain('secret');
+ });
+
+ test('a rejection carries the provider reason rather than a bare status', async () => {
+ const impl = (async () =>
+ new Response('domain not verified', { status: 403 })) as unknown as typeof fetch;
+
+ const mailer = new ResendMailer({ apiKey: 'k', from: 'a@b.com', fetchImpl: impl });
+
+ // Every failure looking identical is what makes "email is broken" take a
+ // day to diagnose instead of a minute.
+ await expect(mailer.send({ to: 'c@d.com', subject: 'x', text: 'y' })).rejects.toThrow(
+ /domain not verified/,
+ );
+ });
+
+ test('the thrown error exposes the status for callers that branch on it', async () => {
+ const impl = (async () => new Response('nope', { status: 429 })) as unknown as typeof fetch;
+
+ try {
+ await new ResendMailer({ apiKey: 'k', from: 'a@b.com', fetchImpl: impl }).send({
+ to: 'c@d.com',
+ subject: 'x',
+ text: 'y',
+ });
+ throw new Error('should have thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(MailerError);
+ expect((error as MailerError).status).toBe(429);
+ }
+ });
+});
+
+describe('ConsoleMailer', () => {
+ test('logs rather than sends, so signup completes with no credentials', async () => {
+ const lines: string[] = [];
+ await new ConsoleMailer((line) => lines.push(line)).send({
+ to: 'a@b.com',
+ subject: 'Confirm',
+ text: 'https://example.com/verify?token=abc',
+ });
+
+ expect(lines[0]).toContain('a@b.com');
+ // The link must be recoverable from the log or local signup is a dead end.
+ expect(lines[0]).toContain('token=abc');
+ });
+});
+
+describe('verificationEmail', () => {
+ test('the plain-text part carries the whole link', async () => {
+ const message = verificationEmail('a@b.com', 'https://og.com/verify?token=xyz');
+
+ expect(message.text).toContain('https://og.com/verify?token=xyz');
+ expect(message.to).toBe('a@b.com');
+ });
+
+ test('a link with markup in it cannot break out of the html', async () => {
+ const message = verificationEmail(
+ 'a@b.com',
+ 'https://og.com/verify?token=">',
+ );
+
+ expect(message.html).not.toContain('