Skip to content

Commit 00de0f6

Browse files
committed
fix(spac): make sec spac process reach a fixpoint
`loadGatedNoOpAccessions` re-selected filings whose handler legitimately wrote nothing, so `sec spac process` re-processed them on every run. Three shapes, each unbounded: - 20-F routes to the 25-15 extractor so an FPI close can record its combination; the classifier ignores every annual one, and the branch selected on "no event". Six annual reports is six filings replayed forever. The 25-15 branch now shares one predicate with the backfill descriptor, `listingRemovalNeedsWork`, which refuses `ignore` and a missing form/date. - A 5.03-only 8-K maps to an event only when the narrative names a new registrant, and the narrative is fetched only for redemption/LOI trigger items — so it writes no event on any run and leaves no dead letter. Dropped from the gated item codes; a 5.03 filed alongside a trigger item is still selected on that code. - An optional-form merger proxy with no merger section is the expected case and writes no extraction row by design, so every ordinary DEF 14A of the 575 SPACs that file one was re-selected forever. The processor now records a RESOLVED SECTION_NOT_FOUND trace, and both predicates read it. Also: `ProcessSpacTimelineTask` computed the gated set before its replay, where a CIK with no spac row yields the empty set — the canonical broken state it targets. The S-1 that mints the row is on the same timeline, so every gated 8-K was filtered out of the one run that could repair it and a second invocation was required. It now recomputes after the replay and runs one capped serial pass over the remainder. And the detector dead-letter lookup read every row of both extractors, memoized per CIK — ~60k rows per SPAC on a batch. Scoped to the issuer's own accessions via `listByAccessions`. Every predicate touched is monotone: processing a filing writes the artifact it keys on. Two "second invocation processes nothing" tests hold that line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
1 parent a0cd1c7 commit 00de0f6

12 files changed

Lines changed: 699 additions & 111 deletions

src/sec/forms/exchange-listing-withdrawal/processDeregistration.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,49 @@ export async function resolveListingRemovalKind(args: {
9494
});
9595
}
9696

97+
/**
98+
* Whether replaying this listing-removal filing can still write something.
99+
*
100+
* The single predicate `sec spac process` and `sec extractor backfill 25-15`
101+
* both select on, so the two cannot drift apart. It is MONOTONE: the only way
102+
* to answer true is that the event the live classifier names is not yet
103+
* recorded on this accession, and processing the filing records exactly that
104+
* event — so a processed filing leaves the set.
105+
*
106+
* Two shapes answer false because {@link processDeregistration} would write
107+
* nothing for them, and re-selecting a filing nothing can be written for is
108+
* pure waste repeated on every sweep:
109+
*
110+
* - a missing `form` or `filing_date` — the handler returns before writing;
111+
* - a classifier verdict of `ignore`, which covers every annual 20-F (the form
112+
* routes here so the FPI CLOSE filing can record a completion) and every
113+
* 20-F filed once a completion is already on the stream. A de-SPAC'd foreign
114+
* private issuer files one of those every year, forever.
115+
*/
116+
export async function listingRemovalNeedsWork(args: {
117+
readonly cik: number;
118+
readonly form: string | null;
119+
readonly filingDate: string | null;
120+
readonly accession_number: string;
121+
readonly ipoDate: string | null;
122+
readonly events: readonly SpacEvent[];
123+
}): Promise<boolean> {
124+
if (args.form == null || args.form === "") return false;
125+
if (args.filingDate == null || args.filingDate === "") return false;
126+
const kind = await resolveListingRemovalKind({
127+
cik: args.cik,
128+
form: args.form,
129+
filingDate: args.filingDate,
130+
accession_number: args.accession_number,
131+
ipoDate: args.ipoDate,
132+
events: args.events,
133+
});
134+
if (kind === "ignore") return false;
135+
return !args.events.some(
136+
(e) => e.event_type === kind && e.accession_number === args.accession_number
137+
);
138+
}
139+
97140
/**
98141
* Record Form 25 / 25-NSE / Form 15 family as a lifecycle event. Exchange
99142
* 25-NSE shortly after IPO is `unit_split` (units unbundle; the vehicle

src/sec/forms/proxies-information-statements/Form_DEFM14A.storage.e2e.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
88
import { afterEach, beforeEach, describe, expect, it } from "vitest";
99
import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI";
1010
import { setupAllDatabases } from "../../../config/setupAllDatabases";
11+
import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo";
1112
import { SpacMergerExtractionRepo } from "../../../storage/spac/SpacMergerExtractionRepo";
1213
import { SpacRepo } from "../../../storage/spac/SpacRepo";
1314
import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter";
@@ -194,6 +195,19 @@ describe("processMergerProxy (e2e)", () => {
194195
expect(events.some((e) => e.event_type === "proxy")).toBe(false);
195196
expect(await new SpacMergerExtractionRepo().getByAccession("121-def14a-ext")).toBeUndefined();
196197
expect((await repo.getSpac(121))?.status).toBe("deal_announced");
198+
199+
// The skip leaves a durable trace, because "no extraction row" is otherwise
200+
// indistinguishable from "the handler was gated and dropped its work" — and
201+
// every general proxy of every known SPAC would be re-selected on every
202+
// sweep, forever. Recorded RESOLVED, so it stays off the worklist an
203+
// operator reads.
204+
const deadLetters = new ExtractionDeadLetterRepo();
205+
const entry = await deadLetters.get("merger-proxy", "121-def14a-ext", "merger");
206+
expect(entry?.status).toBe("resolved");
207+
expect(entry?.reason_code).toBe("SECTION_NOT_FOUND");
208+
expect(
209+
(await deadLetters.listPending("merger-proxy")).map((e) => e.accession_number)
210+
).not.toContain("121-def14a-ext");
197211
});
198212

199213
it("does not emit a proxy event for a preliminary proxy (PRE 14A) that yields a deal", async () => {

src/sec/forms/proxies-information-statements/Form_DEFM14A.storage.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ import {
3333
resolveModelId,
3434
} from "../registration-statements/s1/mergerModel";
3535
import type { FormS1Parsed } from "../registration-statements/Form_S_1";
36-
import { MERGER_PROXY_OPTIONAL_FORMS } from "../../../storage/versioning/extractorIds";
36+
import {
37+
MERGER_PROXY_OPTIONAL_FORMS,
38+
MERGER_PROXY_SECTION,
39+
} from "../../../storage/versioning/extractorIds";
3740

3841
const EXTRACTOR_ID = "merger-proxy";
3942
// Stays 1.0.0: no persisted data to re-extract, so the target_description
4043
// addition needs no version bump (see the S-1 processor for the rationale).
4144
const DEFAULT_EXTRACTOR_VERSION = "1.0.0";
42-
const MERGER_SECTION = "merger";
45+
const MERGER_SECTION = MERGER_PROXY_SECTION;
4346
/**
4447
* Definitive MERGER statements: the form symbol itself says the meeting is
4548
* about a combination, so these emit the `proxy` lifecycle event whether or not
@@ -197,7 +200,29 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
197200
let extractedDeal = false;
198201

199202
if (skipMergerSection) {
200-
// Nothing to extract, and nothing wrong. Fall through to the proxy event.
203+
// Nothing to extract, and nothing wrong — but the skip still needs a
204+
// durable trace. Every predicate that asks "did this proxy produce
205+
// anything" reads the extraction row, and a legitimately absent merger
206+
// section writes none; without a trace, an ordinary annual or extension
207+
// proxy of a known SPAC is indistinguishable from one whose handler was
208+
// gated on a missing spac row and dropped its work, so `spac process` and
209+
// `sec extractor backfill merger-proxy` re-select all 575 SPACs' general
210+
// proxies on every run, forever.
211+
//
212+
// Recorded RESOLVED: this is an answer, not a failure. It never reaches
213+
// `sec extractor dead-letters`, which lists pending entries, so the
214+
// worklist an operator reads is untouched — the same shape as the
215+
// auto-resolved `MODEL_EMPTY` rows the redemption / LOI detectors already
216+
// write per trigger 8-K.
217+
await deadLetters.recordResolved({
218+
extractor_id: EXTRACTOR_ID,
219+
accession_number,
220+
section_name: MERGER_SECTION,
221+
reason_code: "SECTION_NOT_FOUND",
222+
detail: "no merger / business-combination / PIPE section text",
223+
failed_extractor_version: extractor_version,
224+
source_run_id: null,
225+
});
201226
} else if (!model) {
202227
// No model: dead-letter the merger section but still emit the proxy event
203228
// (deterministic, definitive statements only) so the SPAC timeline advances.

src/storage/dead-letter/ExtractionDeadLetterRepo.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,38 @@ export class ExtractionDeadLetterRepo {
132132
});
133133
}
134134

135+
/**
136+
* A durable "ran, and there was legitimately nothing to do" trace.
137+
*
138+
* {@link markResolved} cannot express this: it no-ops when no row exists, and
139+
* an expected no-op never wrote one. Without a trace, a selection predicate
140+
* keyed on "did this section produce anything" cannot tell a handler that
141+
* legitimately wrote nothing from one that was gated and dropped its work, so
142+
* it re-selects the filing on every sweep forever.
143+
*
144+
* A single `put`, so no reader ever observes a spurious `pending` row
145+
* mid-update, and `first_seen_at` is preserved when an earlier failure left
146+
* one. The entry never reaches `sec extractor dead-letters`, which lists
147+
* pending entries only.
148+
*/
149+
async recordResolved(input: DeadLetterInput): Promise<void> {
150+
const now = new Date().toISOString();
151+
const existing = await this.get(input.extractor_id, input.accession_number, input.section_name);
152+
await this.storage.put({
153+
extractor_id: input.extractor_id,
154+
accession_number: input.accession_number,
155+
section_name: input.section_name,
156+
reason_code: input.reason_code,
157+
detail: input.detail,
158+
failed_extractor_version: input.failed_extractor_version,
159+
status: "resolved",
160+
attempts: 0,
161+
first_seen_at: existing?.first_seen_at ?? now,
162+
last_attempt_at: now,
163+
source_run_id: input.source_run_id,
164+
});
165+
}
166+
135167
/** Entries for an extractor carrying a reason code, in any status. */
136168
async listByReasonCode(
137169
extractor_id: string,
@@ -180,6 +212,42 @@ export class ExtractionDeadLetterRepo {
180212
return out;
181213
}
182214

215+
/**
216+
* Entries for the given extractors whose accession is in
217+
* `accession_numbers`, in ANY status — a RESOLVED row is the evidence here,
218+
* not noise: an auto-resolved expected negative, or a
219+
* {@link recordResolved} trace, is the only mark a handler that legitimately
220+
* wrote nothing leaves.
221+
*
222+
* Scoped by accession because the caller already holds one issuer's timeline:
223+
* reading every row of an extractor to answer a question about a dozen
224+
* filings costs the whole table per issuer, and a batch pays that per SPAC.
225+
*
226+
* The extractor ids are looped rather than nested as a second `in` list, so
227+
* each query binds one list; an empty list on either side returns
228+
* immediately (`IN ()` is invalid SQL).
229+
*/
230+
async listByAccessions(
231+
accession_numbers: readonly string[],
232+
extractor_ids: readonly string[]
233+
): Promise<ExtractionDeadLetter[]> {
234+
if (accession_numbers.length === 0 || extractor_ids.length === 0) return [];
235+
const distinct = [...new Set(accession_numbers)];
236+
const out: ExtractionDeadLetter[] = [];
237+
for (const extractor_id of extractor_ids) {
238+
for (let start = 0; start < distinct.length; start += MAX_IDS_PER_QUERY) {
239+
const chunk = distinct.slice(start, start + MAX_IDS_PER_QUERY);
240+
const rows =
241+
(await this.storage.query({
242+
accession_number: { value: chunk, operator: "in" },
243+
extractor_id,
244+
})) ?? [];
245+
out.push(...rows);
246+
}
247+
}
248+
return out;
249+
}
250+
183251
/**
184252
* Pending entries eligible for retry. Four ways in:
185253
*
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { MERGER_PROXY_SECTION } from "../versioning/extractorIds";
8+
import { ExtractionDeadLetterRepo } from "./ExtractionDeadLetterRepo";
9+
10+
/** The extractor whose entries answer for the merger section. */
11+
const MERGER_PROXY_EXTRACTOR_ID = "merger-proxy";
12+
13+
/**
14+
* Of the given accessions, those whose merger-proxy run already answered for
15+
* the merger section — in ANY status.
16+
*
17+
* A RESOLVED entry is the evidence, not noise. Most general proxies carry no
18+
* merger section at all (annual meetings, extension votes), and the processor
19+
* writes no extraction row for one; the resolved `SECTION_NOT_FOUND` trace it
20+
* records instead is the only durable mark that it looked. Both selection
21+
* predicates over merger proxies — `sec spac process` and
22+
* `sec extractor backfill merger-proxy` — read it, so they share one query and
23+
* cannot disagree about what counts as answered.
24+
*/
25+
export async function loadAnsweredMergerSections(
26+
accession_numbers: readonly string[]
27+
): Promise<ReadonlySet<string>> {
28+
const rows = await new ExtractionDeadLetterRepo().listByAccessions(accession_numbers, [
29+
MERGER_PROXY_EXTRACTOR_ID,
30+
]);
31+
return new Set(
32+
rows
33+
.filter((row) => row.section_name === MERGER_PROXY_SECTION)
34+
.map((row) => row.accession_number)
35+
);
36+
}

src/storage/versioning/extractorIds.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ export const MERGER_PROXY_OPTIONAL_FORMS: ReadonlySet<string> = new Set([
268268
"PREA14C",
269269
]);
270270

271+
/**
272+
* Section name the merger-proxy extractor records its deal — and every
273+
* dead-letter entry about it — under. Declared here rather than inside the
274+
* processor because the selection predicates that key on those entries live
275+
* elsewhere: a second spelling makes the trace unreadable to them, which is
276+
* indistinguishable from no trace at all.
277+
*/
278+
export const MERGER_PROXY_SECTION = "merger";
279+
271280
/**
272281
* Short-form registration statements that incorporate an already-filed
273282
* prospectus by reference (Securities Act Rule 462(b)).

src/task/forms/backfillDescriptors.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,30 @@ describe("merger-proxy descriptor", () => {
332332
const todo = await descriptor.filterTodo!(candidates);
333333
expect(todo.map((c) => c.accession_number)).toEqual(["acc-prem"]);
334334
});
335+
336+
it("does not re-select a general proxy whose merger section was legitimately absent", async () => {
337+
// A `DEF 14A` is usually an annual or extension vote carrying no merger
338+
// section, and the processor writes no extraction row for one — by design.
339+
// Selecting on the extraction row alone re-queues every general proxy of
340+
// every known SPAC on every backfill, forever, each one re-paying the
341+
// segmentation and AI cost to conclude again that there is nothing there.
342+
// The resolved SECTION_NOT_FOUND trace is what makes the predicate
343+
// converge.
344+
await seedSpac(5);
345+
await seedFiling({ cik: 5, accession_number: "acc-def14a", form: "DEF 14A" });
346+
await new ExtractionDeadLetterRepo().recordResolved({
347+
extractor_id: "merger-proxy",
348+
accession_number: "acc-def14a",
349+
section_name: "merger",
350+
reason_code: "SECTION_NOT_FOUND",
351+
detail: "no merger / business-combination / PIPE section text",
352+
failed_extractor_version: "1.0.0",
353+
source_run_id: null,
354+
});
355+
356+
const descriptor = getBackfillDescriptor("merger-proxy")!;
357+
expect(await descriptor.filterTodo!(await descriptor.selectCandidates())).toEqual([]);
358+
});
335359
});
336360

337361
describe("25-15 descriptor", () => {
@@ -362,6 +386,25 @@ describe("25-15 descriptor", () => {
362386
expect(todo.map((c) => c.accession_number)).toEqual(["acc-15"]);
363387
});
364388

389+
it("skips a 20-F the classifier ignores", async () => {
390+
// 20-F routes to this extractor so an FPI CLOSE filing can record its
391+
// combination; an ORDINARY annual report classifies `ignore` and writes
392+
// nothing. Selecting it re-runs every annual report of every de-SPAC'd
393+
// foreign private issuer on every backfill — six 20-Fs is six filings
394+
// re-processed forever — so the shared predicate refuses it, while the
395+
// Form 15 beside it is still selected.
396+
await seedSpac(5);
397+
await seedFiling({ cik: 5, accession_number: "acc-20f", form: "20-F" });
398+
await seedFiling({ cik: 5, accession_number: "acc-15", form: "15-12G" });
399+
400+
const descriptor = getBackfillDescriptor("25-15")!;
401+
const candidates = await descriptor.selectCandidates();
402+
expect(candidates.map((c) => c.accession_number).sort()).toEqual(["acc-15", "acc-20f"]);
403+
404+
const todo = await descriptor.filterTodo!(candidates);
405+
expect(todo.map((c) => c.accession_number)).toEqual(["acc-15"]);
406+
});
407+
365408
it("re-selects a 25-NSE recorded as deregistration that is actually unit separation", async () => {
366409
await seedSpac(5);
367410
await new SpacReportWriter().recordIpo({

0 commit comments

Comments
 (0)