From 1507e6cfbbbbfaf86d227bfab2cb27efbea9ca88 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 08:44:18 -0700 Subject: [PATCH 1/3] fix(mailbox-poller): drop numeric separators so the Apps Script editor can parse Code.gs The Apps Script editor rejects numeric separator literals (8_000, 4_000, 1_000_000) with 'Unexpected token ILLEGAL' at line 321 and refuses to save. Plain literals keep the same values. Co-Authored-By: Claude Fable 5.1 --- tools/google-mailbox-poller/Code.gs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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) { From f400d0d4e0ea91435e0feccdb8cde8c287b2bb9a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 09:03:10 -0700 Subject: [PATCH 2/3] fix(lifecycle): own enrichment provenance in code so structured output stops failing Every production enrich job failed. The Anthropic structured-output grammar drops array-length, numeric, and enum bounds from the JSON schema, and the model was asked to echo provenance verbatim. In practice it returned one draft instead of three, dropped milliseconds from retrieved_at, cited score identifiers as sources, and fabricated placeholder sources in neutral mode. Each of those tripped the strict artifact parse or a verifier and the job went terminal after two attempts. The wire schema now carries only what the model judges: summary, confidence, cited signals, company profile, recommended angle, and drafts, with no grammar-unenforceable bounds. The code derives sources from the bounded evidence for cited ids, copies score metadata from the input, drops signals that cite anything outside the evidence, nulls drafts that point at a dropped source or an unknown angle, pads or truncates drafts to the three slots, and strips all company claims in neutral mode. The prompt states the slot count, the allowed angle ids, and which ids are citable. Verified live against the API: five of five runs succeed across company and neutral inputs, where zero succeeded before. Co-Authored-By: Claude Fable 5.1 --- .../src/enrichment/anthropic.spec.ts | 246 +++++++++++++----- apps/lifecycle/src/enrichment/anthropic.ts | 196 +++++++++----- 2 files changed, 318 insertions(+), 124 deletions(-) diff --git a/apps/lifecycle/src/enrichment/anthropic.spec.ts b/apps/lifecycle/src/enrichment/anthropic.spec.ts index 79a8a9daf..079e285a4 100644 --- a/apps/lifecycle/src/enrichment/anthropic.spec.ts +++ b/apps/lifecycle/src/enrichment/anthropic.spec.ts @@ -216,6 +216,58 @@ 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('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 +361,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 +392,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, + 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) + ).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: [{ signal: 'Claim', source_ids: ['source-99'] }], - sources: [{ ...ARTIFACT.sources[0], id: 'source-99' }], + 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) - ).rejects.toThrow(/source/u); - expect(parse).toHaveBeenCalledOnce(); + ).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 +449,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 +464,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 +551,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..920f58f25 100644 --- a/apps/lifecycle/src/enrichment/anthropic.ts +++ b/apps/lifecycle/src/enrichment/anthropic.ts @@ -1,17 +1,135 @@ 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 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 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); + return angle.success + ? { angle_id: angle.data, source_id: draft.source_id } + : null; + }); + 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 +178,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 +209,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 +236,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; } From 7fc0f73d89890abf11e3a07b1acc468977b8c57a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 09:32:28 -0700 Subject: [PATCH 3/3] fix(lifecycle): survive unusable company pages, keep angles distinct, close evidence sequences Three findings from vetting enrichment against live sites: - One oversized, missing, or slow company page aborted evidence for the whole company, and the enrich job then failed after retries. The fetcher now skips such a page and keeps the rest; only the caller's abort and SSRF violations (now CompanyFetchSecurityError) propagate. - Nothing stopped the model from selecting the same angle in two slots, which would send the same email twice. The normalizer keeps the first use of an angle and nulls repeats so those slots fall back to default copy; the prompt asks for distinct angles. - The default day-8 copy says it is the last automated follow-up but the evidence variants did not. renderEvidenceCampaignTemplate takes a finalStep option and the dispatcher sets it for step 3. Co-Authored-By: Claude Fable 5.1 --- apps/lifecycle/src/campaign/send.spec.ts | 34 +++ apps/lifecycle/src/campaign/send.ts | 4 +- apps/lifecycle/src/campaign/templates.spec.ts | 15 ++ apps/lifecycle/src/campaign/templates.ts | 12 +- .../src/enrichment/anthropic.spec.ts | 29 +++ apps/lifecycle/src/enrichment/anthropic.ts | 11 +- .../src/enrichment/company-fetch.spec.ts | 224 +++++++++++++----- .../lifecycle/src/enrichment/company-fetch.ts | 53 +++-- 8 files changed, 301 insertions(+), 81 deletions(-) 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 079e285a4..3f32eb875 100644 --- a/apps/lifecycle/src/enrichment/anthropic.spec.ts +++ b/apps/lifecycle/src/enrichment/anthropic.spec.ts @@ -256,6 +256,35 @@ describe('generateEnrichmentArtifact', () => { ).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, diff --git a/apps/lifecycle/src/enrichment/anthropic.ts b/apps/lifecycle/src/enrichment/anthropic.ts index 920f58f25..8e72e71f8 100644 --- a/apps/lifecycle/src/enrichment/anthropic.ts +++ b/apps/lifecycle/src/enrichment/anthropic.ts @@ -46,7 +46,7 @@ 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 null for a slot with no cited angle. ` + + `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; @@ -104,13 +104,16 @@ function normalizeArtifact( 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); - return angle.success - ? { angle_id: angle.data, source_id: draft.source_id } - : null; + // 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, 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 &lt;script&gt;alert(1)&lt;/script&gt;', - { headers: { 'content-type': 'text/html' } } - ) - ), + fetch: vi + .fn() + .mockResolvedValue( + new Response( + 'Example &lt;script&gt;alert(1)&lt;/script&gt;', + { 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; }