Skip to content

Commit 4c344fa

Browse files
authored
Merge pull request #314 from workglow-dev/claude/sharp-lamport-ata6g5-spac-process-convergence
fix(spac): make sec spac process reach a fixpoint
2 parents a0cd1c7 + 00de0f6 commit 4c344fa

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)