diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index be63d8e51..d3b9edf3d 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -248,6 +248,40 @@ describe('prepareCampaignMessage', () => { ).toMatchObject({ status: 'ready', subject: 'A practical place to start' }); }); + it('closes the sequence on the final step even when evidence copy is selected', () => { + const cited = artifact({ + cited_signals: [ + { signal: 'Bounded source fact', source_ids: ['source-1'] }, + ], + sources: [ + { + id: 'source-1', + url: 'https://example.com/about', + retrieved_at: '2026-09-01T12:00:00.000Z', + content_hash: 'a'.repeat(64), + }, + ], + drafts: [ + { angle_id: 'streaming_foundation', source_id: 'source-1' }, + { angle_id: 'debugging_layers', source_id: 'source-1' }, + { angle_id: 'event_state_boundary', source_id: 'source-1' }, + ], + }); + + const message = prepareCampaignMessage({ + context: context({ enrichmentArtifact: cited }), + job: job('send_step', { campaign_version: 'v1', step: 3 }), + now: new Date('2026-09-09T12:05:00.000Z'), + unsubscribeUrl: UNSUBSCRIBE, + }); + + expect(message).toMatchObject({ + status: 'ready', + subject: 'One event-state boundary', + }); + expect(JSON.stringify(message)).toContain('last automated follow-up'); + }); + it('falls back per fixed step when an artifact draft violates copy checks', () => { const invalid = artifact({ drafts: [ diff --git a/apps/lifecycle/src/campaign/send.ts b/apps/lifecycle/src/campaign/send.ts index 129bd0ba0..78c3d1794 100644 --- a/apps/lifecycle/src/campaign/send.ts +++ b/apps/lifecycle/src/campaign/send.ts @@ -240,7 +240,9 @@ function draftFor( source_ids.includes(selection.source_id) ); if (selection !== null && cited) { - return renderEvidenceCampaignTemplate(selection.angle_id); + return renderEvidenceCampaignTemplate(selection.angle_id, { + finalStep: step === 3, + }); } } return renderCampaignTemplate(STEP_NAMES[step]); diff --git a/apps/lifecycle/src/campaign/templates.spec.ts b/apps/lifecycle/src/campaign/templates.spec.ts index c947a1181..3818a5e43 100644 --- a/apps/lifecycle/src/campaign/templates.spec.ts +++ b/apps/lifecycle/src/campaign/templates.spec.ts @@ -4,6 +4,7 @@ import { campaignDraftViolations, normalizeCampaignDraft, renderCampaignTemplate, + renderEvidenceCampaignTemplate, } from './templates.js'; function wordCount(value: string): number { @@ -34,6 +35,20 @@ describe('renderCampaignTemplate', () => { ); }); + it('appends the last-follow-up notice to an evidence template only for the final step', () => { + const final = renderEvidenceCampaignTemplate('event_state_boundary', { + finalStep: true, + }); + const earlier = renderEvidenceCampaignTemplate('event_state_boundary'); + + expect(final.body).toContain('last automated follow-up'); + expect(final.body.endsWith('This is my last automated follow-up.')).toBe( + true + ); + expect(campaignDraftViolations(final)).toEqual([]); + expect(earlier.body).not.toContain('last automated follow-up'); + }); + it('rejects an unknown campaign step at runtime', () => { expect(() => renderCampaignTemplate('day-30' as never)).toThrow(); }); diff --git a/apps/lifecycle/src/campaign/templates.ts b/apps/lifecycle/src/campaign/templates.ts index 0d9b0944d..e90f3b7ff 100644 --- a/apps/lifecycle/src/campaign/templates.ts +++ b/apps/lifecycle/src/campaign/templates.ts @@ -194,8 +194,16 @@ export function renderCampaignTemplate(step: CampaignStep): CampaignDraft { return normalizeCampaignDraft(CAMPAIGN_TEMPLATES[parsedStep]); } +const FINAL_STEP_NOTICE = 'This is my last automated follow-up.'; + export function renderEvidenceCampaignTemplate( - angle: CampaignEvidenceAngle + angle: CampaignEvidenceAngle, + options: { finalStep?: boolean } = {} ): CampaignDraft { - return normalizeCampaignDraft(EVIDENCE_TEMPLATES[angle]); + const template = EVIDENCE_TEMPLATES[angle]; + return normalizeCampaignDraft( + options.finalStep + ? { ...template, body: `${template.body}\n\n${FINAL_STEP_NOTICE}` } + : template + ); } diff --git a/apps/lifecycle/src/enrichment/anthropic.spec.ts b/apps/lifecycle/src/enrichment/anthropic.spec.ts index 79a8a9daf..3f32eb875 100644 --- a/apps/lifecycle/src/enrichment/anthropic.spec.ts +++ b/apps/lifecycle/src/enrichment/anthropic.spec.ts @@ -216,6 +216,87 @@ describe('generateEnrichmentArtifact', () => { }); }); + it('tells the model there are exactly three campaign slots and names the allowed angle ids', async () => { + const { deps, parse } = dependencies(); + + await generateEnrichmentArtifact(INPUT, SIGNAL, deps); + + const system = String(parse.mock.calls[0]?.[0].system ?? ''); + expect(system).toMatch(/exactly three/u); + expect(system).toMatch(/null/u); + for (const angle of [ + 'streaming_foundation', + 'debugging_layers', + 'event_state_boundary', + ]) { + expect(system).toContain(angle); + } + }); + + it('pads a short drafts array to three slots with null instead of failing', async () => { + const { deps, parse } = dependencies({ + ...ARTIFACT, + drafts: [ARTIFACT.drafts[0]], + }); + + await expect( + generateEnrichmentArtifact(INPUT, SIGNAL, deps) + ).resolves.toEqual({ + ...ARTIFACT, + drafts: [ARTIFACT.drafts[0], null, null], + }); + expect(parse).toHaveBeenCalledOnce(); + }); + + it('pads an empty drafts array to three null slots', async () => { + const { deps } = dependencies({ ...ARTIFACT, drafts: [] }); + + await expect( + generateEnrichmentArtifact(INPUT, SIGNAL, deps) + ).resolves.toEqual({ ...ARTIFACT, drafts: [null, null, null] }); + }); + + it('nulls a repeated angle so that slot falls back to default copy', async () => { + const { deps } = dependencies({ + ...ARTIFACT, + drafts: [ + { angle_id: 'streaming_foundation', source_id: 'source-1' }, + { angle_id: 'streaming_foundation', source_id: 'source-1' }, + { angle_id: 'debugging_layers', source_id: 'source-1' }, + ], + }); + + await expect( + generateEnrichmentArtifact(INPUT, SIGNAL, deps) + ).resolves.toMatchObject({ + drafts: [ + { angle_id: 'streaming_foundation', source_id: 'source-1' }, + null, + { angle_id: 'debugging_layers', source_id: 'source-1' }, + ], + }); + }); + + it('asks the model for distinct angles across the three slots', async () => { + const { deps, parse } = dependencies(); + + await generateEnrichmentArtifact(INPUT, SIGNAL, deps); + + expect(String(parse.mock.calls[0]?.[0].system ?? '')).toMatch(/distinct/u); + }); + + it('truncates more than three drafts to the three campaign slots', async () => { + const { deps, parse } = dependencies({ + ...ARTIFACT, + drafts: [...ARTIFACT.drafts, ARTIFACT.drafts[0]], + }); + + await expect( + generateEnrichmentArtifact(INPUT, SIGNAL, deps) + ).resolves.toEqual(ARTIFACT); + expect(parse).toHaveBeenCalledOnce(); + }); + it('makes exactly one strict messages.parse call with fixed limits, signal, timeout, and retries disabled', async () => { const { deps, createClient, parse } = dependencies(); @@ -309,9 +390,26 @@ describe('generateEnrichmentArtifact', () => { expect(parse).toHaveBeenCalledOnce(); }); - it('rejects model attempts to alter immutable deterministic score metadata', async () => { + it('omits sources and deterministic score fields from the wire schema so the model cannot echo them', async () => { + const { deps, parse } = dependencies(); + + await generateEnrichmentArtifact(INPUT, SIGNAL, deps); + + const format = parse.mock.calls[0]?.[0].output_config?.format as + | { schema?: { properties?: Record } } + | undefined; + const properties = format?.schema?.properties ?? {}; + expect(properties).not.toHaveProperty('sources'); + expect(properties).not.toHaveProperty('score_version'); + expect(properties).not.toHaveProperty('score_reasons'); + expect(properties).toHaveProperty('cited_signals'); + expect(properties).toHaveProperty('drafts'); + }); + + it('always carries the deterministic score metadata from the input, ignoring model output', async () => { const { deps, parse } = dependencies({ ...ARTIFACT, + score_version: 'tampered', score_reasons: [ { code: 'docs.install_command_copied', @@ -323,24 +421,56 @@ describe('generateEnrichmentArtifact', () => { await expect( generateEnrichmentArtifact(INPUT, SIGNAL, deps) - ).rejects.toThrow(/deterministic score/u); + ).resolves.toEqual(ARTIFACT); expect(parse).toHaveBeenCalledOnce(); }); - it('rejects an invented source id even when the evidence metadata matches', async () => { - const { deps, parse } = dependencies({ + it('derives sources from the bounded evidence for cited ids instead of trusting model provenance', async () => { + const { deps } = dependencies({ ...ARTIFACT, - cited_signals: [{ signal: 'Claim', source_ids: ['source-99'] }], - sources: [{ ...ARTIFACT.sources[0], id: 'source-99' }], + sources: [ + { + id: 'source-1', + url: 'https://elsewhere.invalid/', + retrieved_at: '2026-09-01T12:00:00Z', + content_hash: 'f'.repeat(64), + }, + ], }); await expect( generateEnrichmentArtifact(INPUT, SIGNAL, deps) - ).rejects.toThrow(/source/u); - expect(parse).toHaveBeenCalledOnce(); + ).resolves.toEqual(ARTIFACT); + }); + + it('drops signals that cite ids outside the bounded evidence and nulls drafts that pointed at them', async () => { + const { deps } = dependencies({ + ...ARTIFACT, + cited_signals: [ + ...ARTIFACT.cited_signals, + { signal: 'Invented', source_ids: ['once'] }, + { signal: 'Mixed', source_ids: ['source-1', 'source-99'] }, + ], + drafts: [ + ARTIFACT.drafts[0], + { angle_id: 'debugging_layers', source_id: 'source-99' }, + null, + ], + }); + + await expect( + generateEnrichmentArtifact(INPUT, SIGNAL, deps) + ).resolves.toEqual({ + ...ARTIFACT, + cited_signals: [ + ...ARTIFACT.cited_signals, + { signal: 'Mixed', source_ids: ['source-1'] }, + ], + drafts: [ARTIFACT.drafts[0], null, null], + }); }); - it('rejects source ids swapped across two bounded evidence pages', async () => { + it('emits each cited evidence page once, in evidence order, with exact provenance', async () => { const secondPage = { canonicalUrl: 'https://threadplane.ai/about', retrievedAt: '2026-09-01T12:01:00.000Z', @@ -348,17 +478,13 @@ describe('generateEnrichmentArtifact', () => { facts: ['Second fact.'], snippets: ['Second snippet.'], }; - const { deps, parse } = dependencies({ + const { deps } = dependencies({ ...ARTIFACT, - sources: [ - { - id: 'source-1', - url: secondPage.canonicalUrl, - retrieved_at: secondPage.retrievedAt, - content_hash: secondPage.contentHash, - }, - { ...ARTIFACT.sources[0], id: 'source-2' }, + cited_signals: [ + { signal: 'About claim', source_ids: ['source-2', 'source-2'] }, + { signal: 'Home claim', source_ids: ['source-1'] }, ], + sources: [], }); await expect( @@ -367,64 +493,83 @@ describe('generateEnrichmentArtifact', () => { SIGNAL, deps ) - ).rejects.toThrow(/source/u); - expect(parse).toHaveBeenCalledOnce(); + ).resolves.toMatchObject({ + cited_signals: [ + { signal: 'About claim', source_ids: ['source-2'] }, + { signal: 'Home claim', source_ids: ['source-1'] }, + ], + sources: [ + ARTIFACT.sources[0], + { + id: 'source-2', + url: secondPage.canonicalUrl, + retrieved_at: secondPage.retrievedAt, + content_hash: secondPage.contentHash, + }, + ], + }); }); - it('rejects duplicate source ids', async () => { + it('fails closed when company evidence exists but no signal survives filtering', async () => { const { deps, parse } = dependencies({ ...ARTIFACT, - sources: [ARTIFACT.sources[0], ARTIFACT.sources[0]], + cited_signals: [{ signal: 'Invented', source_ids: ['source-99'] }], }); await expect( generateEnrichmentArtifact(INPUT, SIGNAL, deps) - ).rejects.toThrow(/unique/u); + ).rejects.toThrow(/provenance/u); expect(parse).toHaveBeenCalledOnce(); }); - it('rejects company evidence without non-empty sources and cited signals', async () => { - const { deps, parse } = dependencies({ + it('tolerates grammar-unenforceable bounds on the wire and normalizes them before the strict parse', async () => { + const { deps } = dependencies({ ...ARTIFACT, - sources: [], - cited_signals: [], + summary: ARTIFACT.summary, + cited_signals: [ + { signal: 'No ids', source_ids: [] }, + { signal: '', source_ids: ['source-1'] }, + ...Array.from({ length: 9 }, (_, index) => ({ + signal: `Signal ${index + 1}`, + source_ids: ['source-1', 'source-1', 'source-1', 'source-1'], + })), + ], + company_profile: { name: '', description: 'Desc', industry: '' }, + drafts: [ + { angle_id: 'not_an_angle', source_id: 'source-1' }, + { angle_id: 'debugging_layers', source_id: '' }, + { angle_id: 'event_state_boundary', source_id: 'source-1' }, + ], }); - await expect( - generateEnrichmentArtifact(INPUT, SIGNAL, deps) - ).rejects.toThrow(/provenance/u); - expect(parse).toHaveBeenCalledOnce(); + const artifact = await generateEnrichmentArtifact(INPUT, SIGNAL, deps); + + expect(artifact.cited_signals).toHaveLength(8); + expect(artifact.cited_signals[0]).toEqual({ + signal: 'Signal 1', + source_ids: ['source-1'], + }); + expect(artifact.company_profile).toEqual({ + name: null, + description: 'Desc', + industry: null, + }); + expect(artifact.drafts).toEqual([ + null, + null, + { angle_id: 'event_state_boundary', source_id: 'source-1' }, + ]); }); - it('rejects an emitted source that no cited signal references', async () => { - const secondPage = { - canonicalUrl: 'https://threadplane.ai/about', - retrievedAt: '2026-09-01T12:01:00.000Z', - contentHash: 'b'.repeat(64), - facts: ['Second fact.'], - snippets: ['Second snippet.'], - }; - const { deps, parse } = dependencies({ - ...ARTIFACT, - sources: [ - ARTIFACT.sources[0], - { - id: 'source-2', - url: secondPage.canonicalUrl, - retrieved_at: secondPage.retrievedAt, - content_hash: secondPage.contentHash, - }, - ], + it('accepts a neutral response whose only signal has no source ids', async () => { + const { deps } = dependencies({ + ...NEUTRAL_ARTIFACT, + cited_signals: [{ signal: 'Form submitted', source_ids: [] }], }); await expect( - generateEnrichmentArtifact( - { ...INPUT, companyPages: [...INPUT.companyPages, secondPage] }, - SIGNAL, - deps - ) - ).rejects.toThrow(/uncited source/u); - expect(parse).toHaveBeenCalledOnce(); + generateEnrichmentArtifact(NEUTRAL_INPUT, SIGNAL, deps) + ).resolves.toEqual(NEUTRAL_ARTIFACT); }); it('accepts a null-profile neutral artifact without company provenance', async () => { @@ -435,19 +580,29 @@ describe('generateEnrichmentArtifact', () => { ).resolves.toEqual(NEUTRAL_ARTIFACT); }); - it('rejects neutral-mode company claims', async () => { + it('strips neutral-mode company claims, fabricated sources, and drafts', async () => { const { deps, parse } = dependencies({ ...NEUTRAL_ARTIFACT, company_profile: { name: 'Claimed Company', - description: null, + description: 'Made up', industry: null, }, + cited_signals: [{ signal: 'Invented', source_ids: ['contact'] }], + sources: [ + { + id: 'contact', + url: 'https://placeholder.invalid/contact', + retrieved_at: '2024-01-01T00:00:00.000Z', + content_hash: '0'.repeat(64), + }, + ], + drafts: [{ angle_id: 'streaming_foundation', source_id: 'contact' }], }); await expect( generateEnrichmentArtifact(NEUTRAL_INPUT, SIGNAL, deps) - ).rejects.toThrow(/neutral provenance/iu); + ).resolves.toEqual(NEUTRAL_ARTIFACT); expect(parse).toHaveBeenCalledOnce(); }); }); diff --git a/apps/lifecycle/src/enrichment/anthropic.ts b/apps/lifecycle/src/enrichment/anthropic.ts index 1d5f867ac..8e72e71f8 100644 --- a/apps/lifecycle/src/enrichment/anthropic.ts +++ b/apps/lifecycle/src/enrichment/anthropic.ts @@ -1,17 +1,138 @@ import Anthropic from '@anthropic-ai/sdk'; +import { z } from 'zod'; import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; -import { EnrichmentArtifactSchema, type EnrichmentArtifact } from './schema.js'; +import { + CampaignEvidenceAngleSchema, + EnrichmentArtifactSchema, + type EnrichmentArtifact, +} from './schema.js'; import type { ResearchInput } from './research-input.js'; const DEFAULT_MODEL = 'claude-sonnet-4-6'; const MAX_TOKENS = 1_200; const TIMEOUT_MS = 30_000; -// SDK 0.79's declaration resolves `zod` from the workspace root while its -// implementation deliberately consumes `zod/v4`. This app pins its own Zod 4. +const CAMPAIGN_SLOT_COUNT = 3; +// The model judges only what it can judge. Provenance (`sources`) and the +// deterministic score fields are owned by this code and absent from the wire +// schema. The API's structured-output grammar also drops every length, min, +// and enum bound, so none appear here; normalizeArtifact applies them before +// the strict EnrichmentArtifactSchema parse. SDK 0.79's declaration resolves +// `zod` from the workspace root while its implementation consumes `zod/v4`; +// this app pins its own Zod 4, hence the cast at zodOutputFormat. +const WIRE_ARTIFACT_SCHEMA = z + .object({ + summary: z.string(), + confidence: z.enum(['low', 'medium', 'high']), + cited_signals: z.array( + z.object({ signal: z.string(), source_ids: z.array(z.string()) }) + ), + company_profile: z.object({ + name: z.string().nullable(), + description: z.string().nullable(), + industry: z.string().nullable(), + }), + recommended_angle: z.string(), + drafts: z.array( + z.object({ angle_id: z.string(), source_id: z.string() }).nullable() + ), + }) + .strip(); const ARTIFACT_OUTPUT_FORMAT = zodOutputFormat( - EnrichmentArtifactSchema as unknown as Parameters[0] + WIRE_ARTIFACT_SCHEMA as unknown as Parameters[0] ); +const ANGLE_IDS = CampaignEvidenceAngleSchema.options.join(', '); +const SYSTEM_PROMPT = + 'Produce one bounded factual research artifact from the supplied evidence. ' + + 'The only citable source ids are the ids of the supplied companyPages entries; score identifiers, form fields, and project ids are not sources. ' + + 'Use neutral language for unknowns and leave company_profile fields null when no companyPages evidence supports them. ' + + `drafts must contain exactly three entries, one per campaign slot in order. Each entry is either null or an object selecting one angle_id from [${ANGLE_IDS}] plus one cited companyPages source_id; use a distinct angle_id in each slot and null for a slot with no cited angle. ` + + 'Never write recipient prose or personalized claims.'; + +type WireArtifact = z.infer; + +// Any echo of the code-owned fields is discarded before the strict wire parse. +const CODE_OWNED_FIELDS = new Set([ + 'sources', + 'score_version', + 'score_reasons', +]); +function stripCodeOwnedFields(parsedOutput: unknown): unknown { + if (typeof parsedOutput !== 'object' || parsedOutput === null) { + return parsedOutput; + } + return Object.fromEntries( + Object.entries(parsedOutput as Record).filter( + ([key]) => !CODE_OWNED_FIELDS.has(key) + ) + ); +} + +const MAX_CITED_SIGNALS = 8; +const MAX_SIGNAL_SOURCE_IDS = 3; + +function nullIfBlank(value: string | null): string | null { + return value !== null && value.trim().length > 0 ? value : null; +} + +function normalizeArtifact( + wire: WireArtifact, + input: ResearchInput +): EnrichmentArtifact { + const evidence = new Map( + input.companyPages.map((page, index) => [`source-${index + 1}`, page]) + ); + const neutral = input.researchMode === 'neutral'; + const citedSignals = neutral + ? [] + : wire.cited_signals + .flatMap((signal) => { + const sourceIds = [ + ...new Set(signal.source_ids.filter((id) => evidence.has(id))), + ].slice(0, MAX_SIGNAL_SOURCE_IDS); + return sourceIds.length === 0 || signal.signal.trim().length === 0 + ? [] + : [{ signal: signal.signal, source_ids: sourceIds }]; + }) + .slice(0, MAX_CITED_SIGNALS); + const citedIds = new Set(citedSignals.flatMap((signal) => signal.source_ids)); + const sources = [...evidence] + .filter(([id]) => citedIds.has(id)) + .map(([id, page]) => ({ + id, + url: page.canonicalUrl, + retrieved_at: page.retrievedAt, + content_hash: page.contentHash, + })); + const usedAngles = new Set(); + const drafts = Array.from({ length: CAMPAIGN_SLOT_COUNT }, (_, index) => { + const draft = wire.drafts[index] ?? null; + if (draft === null || !citedIds.has(draft.source_id)) return null; + const angle = CampaignEvidenceAngleSchema.safeParse(draft.angle_id); + // A repeated angle would send the same email twice, so only the first + // slot keeps it and later repeats fall back to the step's default copy. + if (!angle.success || usedAngles.has(angle.data)) return null; + usedAngles.add(angle.data); + return { angle_id: angle.data, source_id: draft.source_id }; + }); + return { + summary: wire.summary, + confidence: wire.confidence, + cited_signals: citedSignals, + company_profile: neutral + ? { name: null, description: null, industry: null } + : { + name: nullIfBlank(wire.company_profile.name), + description: nullIfBlank(wire.company_profile.description), + industry: nullIfBlank(wire.company_profile.industry), + }, + score_version: input.deterministicScore.scoreVersion, + score_reasons: input.deterministicScore.reasons, + recommended_angle: wire.recommended_angle, + sources, + drafts, + }; +} interface AnthropicClientOptions { apiKey: string; @@ -60,71 +181,17 @@ function modelInput(input: ResearchInput): object { }; } -function verifyDeterministicFields( +function verifyArtifactInvariants( artifact: EnrichmentArtifact, input: ResearchInput ): void { - if ( - artifact.score_version !== input.deterministicScore.scoreVersion || - JSON.stringify(artifact.score_reasons) !== - JSON.stringify(input.deterministicScore.reasons) - ) { - throw new Error('Model altered immutable deterministic score metadata'); - } - - if (input.researchMode === 'neutral') { - const profileValues = Object.values(artifact.company_profile); - if ( - artifact.sources.length !== 0 || - artifact.cited_signals.length !== 0 || - profileValues.some((value) => value !== null) - ) { - throw new Error('Neutral provenance must contain no company claims'); - } - return; - } - + if (input.researchMode === 'neutral') return; if ( input.companyPages.length > 0 && (artifact.sources.length === 0 || artifact.cited_signals.length === 0) ) { throw new Error('Company evidence requires non-empty provenance'); } - - const expectedSources = new Map( - input.companyPages.map((page, index) => [`source-${index + 1}`, page]) - ); - const citedSourceIds = new Set( - artifact.cited_signals.flatMap((signal) => signal.source_ids) - ); - const sourceIds = new Set(); - for (const source of artifact.sources) { - if (sourceIds.has(source.id)) - throw new Error('Artifact source ids must be unique'); - sourceIds.add(source.id); - const evidence = expectedSources.get(source.id); - if ( - !evidence || - evidence.canonicalUrl !== source.url || - evidence.retrievedAt !== source.retrieved_at || - evidence.contentHash !== source.content_hash - ) { - throw new Error('Artifact cited a source outside the bounded evidence'); - } - if (!citedSourceIds.has(source.id)) { - throw new Error(`Artifact emitted uncited source: ${source.id}`); - } - } - for (const signal of artifact.cited_signals) { - if (signal.source_ids.some((sourceId) => !sourceIds.has(sourceId))) { - throw new Error('Artifact signal cited an unknown source'); - } - } - for (const selection of artifact.drafts) { - if (selection !== null && !citedSourceIds.has(selection.source_id)) { - throw new Error('Artifact campaign angle selected uncited evidence'); - } - } } export async function generateEnrichmentArtifact( @@ -145,8 +212,7 @@ export async function generateEnrichmentArtifact( { model: configuredModel || DEFAULT_MODEL, max_tokens: MAX_TOKENS, - system: - 'Produce one bounded factual research artifact from the supplied evidence. Cite only supplied source ids, use neutral language for unknowns, and preserve score_version and score_reasons exactly. For each campaign slot select only one allowed angle_id and a cited source_id; never write recipient prose or personalized claims.', + system: SYSTEM_PROMPT, messages: [ { role: 'user', @@ -173,7 +239,12 @@ export async function generateEnrichmentArtifact( if (response.parsed_output === null) { throw new Error('Anthropic returned no structured enrichment output'); } - const artifact = EnrichmentArtifactSchema.parse(response.parsed_output); - verifyDeterministicFields(artifact, input); + const wire = WIRE_ARTIFACT_SCHEMA.parse( + stripCodeOwnedFields(response.parsed_output) + ); + const artifact = EnrichmentArtifactSchema.parse( + normalizeArtifact(wire, input) + ); + verifyArtifactInvariants(artifact, input); return artifact; } diff --git a/apps/lifecycle/src/enrichment/company-fetch.spec.ts b/apps/lifecycle/src/enrichment/company-fetch.spec.ts index ce648673b..a834cf3ad 100644 --- a/apps/lifecycle/src/enrichment/company-fetch.spec.ts +++ b/apps/lifecycle/src/enrichment/company-fetch.spec.ts @@ -36,6 +36,67 @@ function dependencies( }; } +function okPage(): Response { + return new Response( + 'Example

Example company

Safe public evidence.

', + { status: 200, headers: { 'content-type': 'text/html' } } + ); +} + +describe('fetchCompanyEvidence page resilience', () => { + it('skips a page that answers 404 and keeps the others', async () => { + const fetch = vi.fn(async (url: URL) => + url.pathname === '/about' ? new Response(null, { status: 404 }) : okPage() + ); + const deps = dependencies({ fetch }); + + const evidence = await fetchCompanyEvidence( + 'example.com', + new AbortController().signal, + deps + ); + + expect(evidence.map((page) => page.canonicalUrl)).toEqual([ + 'https://example.com/', + 'https://example.com/pricing', + ]); + }); + + it('returns no evidence when every page fails instead of throwing', async () => { + const deps = dependencies({ + fetch: vi.fn(async () => new Response(null, { status: 503 })), + }); + + await expect( + fetchCompanyEvidence('example.com', new AbortController().signal, deps) + ).resolves.toEqual([]); + expect(deps.fetch).toHaveBeenCalledTimes(3); + }); + + it('still rejects when the caller aborts mid-way', async () => { + const parent = new AbortController(); + const fetch = vi.fn(async () => { + parent.abort(new Error('caller aborted')); + throw new Error('page failed'); + }); + const deps = dependencies({ fetch }); + + await expect( + fetchCompanyEvidence('example.com', parent.signal, deps) + ).rejects.toThrow(/caller aborted/u); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('still rejects an invalid company domain before fetching anything', async () => { + const deps = dependencies(); + + await expect( + fetchCompanyEvidence('not a domain', new AbortController().signal, deps) + ).rejects.toThrow(/company_domain/u); + expect(deps.fetch).not.toHaveBeenCalled(); + }); +}); + describe('fetchCompanyEvidence SSRF controls', () => { it('shares one five-second deadline across DNS and every redirect for a page', async () => { vi.useFakeTimers(); @@ -136,14 +197,20 @@ describe('fetchCompanyEvidence SSRF controls', () => { request, } ); - const rejection = expect(result).rejects.toMatchObject({ + await vi.advanceTimersByTimeAsync(5_000); + const [firstOptions] = request.mock.calls[0] ?? []; + expect(firstOptions?.signal?.aborted).toBe(true); + expect(firstOptions?.signal?.reason).toMatchObject({ name: 'TimeoutError', }); + // The timed-out page is skipped; the remaining two pages each get + // their own five-second deadline and the call resolves without + // evidence rather than rejecting. await vi.advanceTimersByTimeAsync(5_000); - - await rejection; - expect(request).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(result).resolves.toEqual([]); + expect(request).toHaveBeenCalledTimes(3); } finally { vi.useRealTimers(); } @@ -228,7 +295,7 @@ describe('fetchCompanyEvidence SSRF controls', () => { request, createTimeoutSignal: (signal) => ({ signal, clear: vi.fn() }), }) - ).rejects.toThrow(/HTTP 500/u); + ).resolves.toEqual([]); expect(firstDestroy).toHaveBeenCalled(); }); @@ -261,7 +328,7 @@ describe('fetchCompanyEvidence SSRF controls', () => { request, createTimeoutSignal: (signal) => ({ signal, clear: vi.fn() }), }) - ).rejects.toBeInstanceOf(RangeError); + ).resolves.toEqual([]); expect(incoming?.destroy).toHaveBeenCalledOnce(); expect(incoming?.destroyed).toBe(true); @@ -407,8 +474,8 @@ describe('fetchCompanyEvidence SSRF controls', () => { await expect( fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/redirect limit/u); - expect(deps.fetch).toHaveBeenCalledTimes(4); + ).resolves.toEqual([]); + expect(deps.fetch).toHaveBeenCalledTimes(6); }); it('cancels a redirect response body before following it', async () => { @@ -426,7 +493,7 @@ describe('fetchCompanyEvidence SSRF controls', () => { await expect( fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/stop after redirect/u); + ).resolves.toEqual([]); expect(cancel).toHaveBeenCalledOnce(); }); @@ -435,24 +502,48 @@ describe('fetchCompanyEvidence SSRF controls', () => { const response = new Response(new ReadableStream({ cancel }), { status: 500, }); - const deps = dependencies({ fetch: vi.fn().mockResolvedValue(response) }); + const deps = dependencies({ + fetch: vi + .fn() + .mockResolvedValueOnce(response) + .mockImplementation(async () => okPage()), + }); - await expect( - fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/HTTP 500/u); + const evidence = await fetchCompanyEvidence( + 'example.com', + new AbortController().signal, + deps + ); + + expect(evidence.map((page) => page.canonicalUrl)).toEqual([ + 'https://example.com/about', + 'https://example.com/pricing', + ]); expect(cancel).toHaveBeenCalledOnce(); }); - it('cancels an advertised oversized body before throwing', async () => { + it('cancels an advertised oversized body, skips that page, and keeps the others', async () => { const cancel = vi.fn(); const response = new Response(new ReadableStream({ cancel }), { headers: { 'content-length': String(250 * 1024 + 1) }, }); - const deps = dependencies({ fetch: vi.fn().mockResolvedValue(response) }); + const deps = dependencies({ + fetch: vi + .fn() + .mockResolvedValueOnce(response) + .mockImplementation(async () => okPage()), + }); - await expect( - fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/250 KiB/u); + const evidence = await fetchCompanyEvidence( + 'example.com', + new AbortController().signal, + deps + ); + + expect(evidence.map((page) => page.canonicalUrl)).toEqual([ + 'https://example.com/about', + 'https://example.com/pricing', + ]); expect(cancel).toHaveBeenCalledOnce(); }); @@ -468,12 +559,15 @@ describe('fetchCompanyEvidence SSRF controls', () => { cancel, }); const deps = dependencies({ - fetch: vi.fn().mockResolvedValue(new Response(body)), + fetch: vi + .fn() + .mockResolvedValueOnce(new Response(body)) + .mockImplementation(async () => okPage()), }); await expect( fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/250 KiB/u); + ).resolves.toHaveLength(2); expect(reads).toBeGreaterThanOrEqual(2); expect(cancel).toHaveBeenCalledOnce(); }); @@ -491,11 +585,16 @@ describe('fetchCompanyEvidence SSRF controls', () => { cancel, releaseLock, } as unknown as ReadableStreamDefaultReader); - const deps = dependencies({ fetch: vi.fn().mockResolvedValue(response) }); + const deps = dependencies({ + fetch: vi + .fn() + .mockResolvedValueOnce(response) + .mockImplementation(async () => okPage()), + }); await expect( fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/body read failed/u); + ).resolves.toHaveLength(2); expect(cancel).toHaveBeenCalledOnce(); expect(releaseLock).toHaveBeenCalledOnce(); }); @@ -514,7 +613,7 @@ describe('fetchCompanyEvidence SSRF controls', () => { await expect( fetchCompanyEvidence('example.com', new AbortController().signal, deps) - ).rejects.toThrow(/timed out/u); + ).resolves.toEqual([]); expect(createTimeoutSignal).toHaveBeenCalledWith( expect.any(AbortSignal), 5_000 @@ -527,12 +626,14 @@ describe('fetchCompanyEvidence SSRF controls', () => { it('decodes HTML entities only once when extracting evidence', async () => { const deps = dependencies({ - fetch: vi.fn().mockResolvedValue( - new Response( - 'Example <script>alert(1)</script>', - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + 'Example <script>alert(1)</script>', + { headers: { 'content-type': 'text/html' } } + ) + ), }); const [evidence] = await fetchCompanyEvidence( @@ -549,12 +650,14 @@ describe('fetchCompanyEvidence SSRF controls', () => { it('removes executable elements whose closing tag contains whitespace', async () => { const deps = dependencies({ - fetch: vi.fn().mockResolvedValue( - new Response( - '

Safe public evidence.

', - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + '

Safe public evidence.

', + { headers: { 'content-type': 'text/html' } } + ) + ), }); const [evidence] = await fetchCompanyEvidence( @@ -571,12 +674,14 @@ describe('fetchCompanyEvidence SSRF controls', () => { it('preserves document order across paragraph and list-item snippets', async () => { const deps = dependencies({ - fetch: vi.fn().mockResolvedValue( - new Response( - '
  • First evidence.
  • Second evidence.

    ', - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + '
  • First evidence.
  • Second evidence.

    ', + { headers: { 'content-type': 'text/html' } } + ) + ), }); const [evidence] = await fetchCompanyEvidence( @@ -585,20 +690,19 @@ describe('fetchCompanyEvidence SSRF controls', () => { deps ); - expect(evidence?.snippets).toEqual([ - 'First evidence.', - 'Second evidence.', - ]); + expect(evidence?.snippets).toEqual(['First evidence.', 'Second evidence.']); }); it('excludes executable descendants nested inside evidence elements', async () => { const deps = dependencies({ - fetch: vi.fn().mockResolvedValue( - new Response( - '

    Safe evidence.

    ', - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + '

    Safe evidence.

    ', + { headers: { 'content-type': 'text/html' } } + ) + ), }); const [evidence] = await fetchCompanyEvidence( @@ -612,7 +716,9 @@ describe('fetchCompanyEvidence SSRF controls', () => { it('handles deeply nested bounded HTML without exhausting the call stack', async () => { const depth = 18_000; - const body = `

    ${''.repeat(depth)}Safe evidence.${''.repeat(depth)}

    `; + const body = `

    ${''.repeat( + depth + )}Safe evidence.${''.repeat(depth)}

    `; const deps = dependencies({ fetch: vi.fn().mockResolvedValue( new Response(body, { @@ -633,12 +739,14 @@ describe('fetchCompanyEvidence SSRF controls', () => { it('applies the snippet limit after removing duplicates', async () => { const duplicates = '

    Duplicate evidence.

    '.repeat(6); const deps = dependencies({ - fetch: vi.fn().mockResolvedValue( - new Response( - `${duplicates}

    Unique evidence.

    `, - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + `${duplicates}

    Unique evidence.

    `, + { headers: { 'content-type': 'text/html' } } + ) + ), }); const [evidence] = await fetchCompanyEvidence( diff --git a/apps/lifecycle/src/enrichment/company-fetch.ts b/apps/lifecycle/src/enrichment/company-fetch.ts index 74a651855..d90b29dcb 100644 --- a/apps/lifecycle/src/enrichment/company-fetch.ts +++ b/apps/lifecycle/src/enrichment/company-fetch.ts @@ -25,6 +25,16 @@ const MAX_PAGE_BYTES = 250 * 1024; const REQUEST_TIMEOUT_MS = 5_000; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +// Raised when a target fails the SSRF controls. Unlike a transport or +// content failure, a security violation never degrades to "no evidence"; +// it propagates so the caller can treat the domain as suspect. +export class CompanyFetchSecurityError extends Error { + constructor(message: string) { + super(message); + this.name = 'CompanyFetchSecurityError'; + } +} + export interface CompanyRequestInit extends RequestInit { resolvedAddresses: readonly string[]; } @@ -175,7 +185,9 @@ function pinnedHttpsFetch( ): Promise { const address = init.resolvedAddresses[0]; if (!address || !isPublicAddress(address)) { - throw new Error('Pinned HTTPS request requires a validated public address'); + throw new CompanyFetchSecurityError( + 'Pinned HTTPS request requires a validated public address' + ); } const headers = new Headers(init.headers); headers.set('host', url.hostname); @@ -244,7 +256,7 @@ function validatedCompanyHostname(companyDomain: string): string { companyDomain ) ) { - throw new Error('Invalid company_domain'); + throw new CompanyFetchSecurityError('Invalid company_domain'); } return companyDomain.toLowerCase(); } @@ -349,7 +361,9 @@ async function resolvePublicAddresses( if (addresses.length === 0) throw new Error('Company domain did not resolve'); for (const address of addresses) { if (!isPublicAddress(address)) { - throw new Error(`Company domain resolved to unsafe address: ${address}`); + throw new CompanyFetchSecurityError( + `Company domain resolved to unsafe address: ${address}` + ); } } return addresses; @@ -364,7 +378,7 @@ function validatedRedirectUrl( try { redirect = new URL(location, current); } catch { - throw new Error('Invalid company redirect'); + throw new CompanyFetchSecurityError('Invalid company redirect'); } if ( redirect.protocol !== 'https:' || @@ -373,7 +387,7 @@ function validatedRedirectUrl( (redirect.port !== '' && redirect.port !== '443') || redirect.hostname.toLowerCase() !== hostname ) { - throw new Error('Unsafe company redirect'); + throw new CompanyFetchSecurityError('Unsafe company redirect'); } return redirect; } @@ -435,10 +449,7 @@ async function readBoundedBody(response: Response): Promise { } function cleanText(value: string): string { - return value - .replace(/\s+/gu, ' ') - .trim() - .slice(0, 240); + return value.replace(/\s+/gu, ' ').trim().slice(0, 240); } const EXECUTABLE_ELEMENTS = new Set(['script', 'style', 'noscript']); @@ -449,10 +460,7 @@ function nodeText(node: DefaultTreeAdapterTypes.Node): string { while (pending.length > 0) { const candidate = pending.pop(); if (!candidate) break; - if ( - 'tagName' in candidate && - EXECUTABLE_ELEMENTS.has(candidate.tagName) - ) { + if ('tagName' in candidate && EXECUTABLE_ELEMENTS.has(candidate.tagName)) { continue; } if (candidate.nodeName === '#text') { @@ -460,7 +468,11 @@ function nodeText(node: DefaultTreeAdapterTypes.Node): string { continue; } if ('childNodes' in candidate) { - for (let index = candidate.childNodes.length - 1; index >= 0; index -= 1) { + for ( + let index = candidate.childNodes.length - 1; + index >= 0; + index -= 1 + ) { const child = candidate.childNodes[index]; if (child) pending.push(child); } @@ -483,7 +495,11 @@ function collectElements( if (tagNames.has(candidate.tagName)) elements.push(candidate); } if ('childNodes' in candidate) { - for (let index = candidate.childNodes.length - 1; index >= 0; index -= 1) { + for ( + let index = candidate.childNodes.length - 1; + index >= 0; + index -= 1 + ) { const child = candidate.childNodes[index]; if (child) pending.push(child); } @@ -605,10 +621,15 @@ export async function fetchCompanyEvidence( ); break; } + } catch (error) { + // A page that is oversized, missing, slow, or otherwise unusable is + // skipped so the remaining pages still yield evidence. The caller's + // own abort and any SSRF violation propagate. + signal.throwIfAborted(); + if (error instanceof CompanyFetchSecurityError) throw error; } finally { timeout.clear(); } } - return evidence; } diff --git a/tools/google-mailbox-poller/Code.gs b/tools/google-mailbox-poller/Code.gs index 1481b28ad..7cdcd156e 100644 --- a/tools/google-mailbox-poller/Code.gs +++ b/tools/google-mailbox-poller/Code.gs @@ -192,7 +192,7 @@ function gmailSeedVerificationFromMessage_(message) { function referenceMessageIds_(value) { if (typeof value !== 'string') return []; - var boundedValue = value.slice(Math.max(0, value.length - 8_000)); + var boundedValue = value.slice(Math.max(0, value.length - 8000)); var matches = boundedValue.match(/<[^<>\s\r\n]+>/g) || []; var normalized = matches .map(normalizedRfcMessageId_) @@ -203,7 +203,7 @@ function referenceMessageIds_(value) { var total = normalized.reduce(function (sum, messageId) { return sum + messageId.length; }, 0); - while (normalized.length > 0 && total > 4_000) { + while (normalized.length > 0 && total > 4000) { total -= normalized.shift().length; } return normalized; @@ -318,7 +318,7 @@ function validPageToken_(value) { } function validSourceOffset_(value) { - return Number.isInteger(value) && value >= 0 && value <= 1_000_000; + return Number.isInteger(value) && value >= 0 && value <= 1000000; } function validPageState_(page) {