Skip to content

Commit f1b388d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/sharp-lamport-ata6g5-proxy-approval-gate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
2 parents e8663e8 + 4c344fa commit f1b388d

12 files changed

Lines changed: 710 additions & 121 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";
@@ -286,6 +287,19 @@ describe("processMergerProxy (e2e)", () => {
286287
expect(events.some((e) => e.event_type === "proxy")).toBe(false);
287288
expect(await new SpacMergerExtractionRepo().getByAccession("121-def14a-ext")).toBeUndefined();
288289
expect((await repo.getSpac(121))?.status).toBe("deal_announced");
290+
291+
// The skip leaves a durable trace, because "no extraction row" is otherwise
292+
// indistinguishable from "the handler was gated and dropped its work" — and
293+
// every general proxy of every known SPAC would be re-selected on every
294+
// sweep, forever. Recorded RESOLVED, so it stays off the worklist an
295+
// operator reads.
296+
const deadLetters = new ExtractionDeadLetterRepo();
297+
const entry = await deadLetters.get("merger-proxy", "121-def14a-ext", "merger");
298+
expect(entry?.status).toBe("resolved");
299+
expect(entry?.reason_code).toBe("SECTION_NOT_FOUND");
300+
expect(
301+
(await deadLetters.listPending("merger-proxy")).map((e) => e.accession_number)
302+
).not.toContain("121-def14a-ext");
289303
});
290304

291305
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: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ import type { FormS1Parsed } from "../registration-statements/Form_S_1";
4141
import {
4242
GENERAL_DEFINITIVE_PROXY_FORMS,
4343
MERGER_PROXY_OPTIONAL_FORMS,
44+
MERGER_PROXY_SECTION,
4445
} from "../../../storage/versioning/extractorIds";
4546
import { seeksCombinationApproval } from "./seeksCombinationApproval";
4647

4748
const EXTRACTOR_ID = "merger-proxy";
4849
// Stays 1.0.0: no persisted data to re-extract, so the target_description
4950
// addition needs no version bump (see the S-1 processor for the rationale).
5051
const DEFAULT_EXTRACTOR_VERSION = "1.0.0";
51-
const MERGER_SECTION = "merger";
52+
const MERGER_SECTION = MERGER_PROXY_SECTION;
5253
/**
5354
* Definitive MERGER statements: the form symbol itself says the meeting is
5455
* about a combination, so these emit the `proxy` lifecycle event whether or not
@@ -197,7 +198,29 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
197198
let extractedDeal = false;
198199

199200
if (skipMergerSection) {
200-
// Nothing to extract, and nothing wrong. Fall through to the proxy event.
201+
// Nothing to extract, and nothing wrong — but the skip still needs a
202+
// durable trace. Every predicate that asks "did this proxy produce
203+
// anything" reads the extraction row, and a legitimately absent merger
204+
// section writes none; without a trace, an ordinary annual or extension
205+
// proxy of a known SPAC is indistinguishable from one whose handler was
206+
// gated on a missing spac row and dropped its work, so `spac process` and
207+
// `sec extractor backfill merger-proxy` re-select all 575 SPACs' general
208+
// proxies on every run, forever.
209+
//
210+
// Recorded RESOLVED: this is an answer, not a failure. It never reaches
211+
// `sec extractor dead-letters`, which lists pending entries, so the
212+
// worklist an operator reads is untouched — the same shape as the
213+
// auto-resolved `MODEL_EMPTY` rows the redemption / LOI detectors already
214+
// write per trigger 8-K.
215+
await deadLetters.recordResolved({
216+
extractor_id: EXTRACTOR_ID,
217+
accession_number,
218+
section_name: MERGER_SECTION,
219+
reason_code: "SECTION_NOT_FOUND",
220+
detail: "no merger / business-combination / PIPE section text",
221+
failed_extractor_version: extractor_version,
222+
source_run_id: null,
223+
});
201224
} else if (!model) {
202225
// No model: dead-letter the merger section but still emit the proxy event
203226
// (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
@@ -287,6 +287,15 @@ export const MERGER_PROXY_OPTIONAL_FORMS: ReadonlySet<string> = new Set([
287287
"PREA14C",
288288
]);
289289

290+
/**
291+
* Section name the merger-proxy extractor records its deal — and every
292+
* dead-letter entry about it — under. Declared here rather than inside the
293+
* processor because the selection predicates that key on those entries live
294+
* elsewhere: a second spelling makes the trace unreadable to them, which is
295+
* indistinguishable from no trace at all.
296+
*/
297+
export const MERGER_PROXY_SECTION = "merger";
298+
290299
/**
291300
* Short-form registration statements that incorporate an already-filed
292301
* 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
@@ -398,6 +398,30 @@ describe("merger-proxy descriptor", () => {
398398
const candidates = await descriptor.selectCandidates();
399399
expect(await descriptor.filterTodo!(candidates)).toEqual([]);
400400
});
401+
402+
it("does not re-select a general proxy whose merger section was legitimately absent", async () => {
403+
// A `DEF 14A` is usually an annual or extension vote carrying no merger
404+
// section, and the processor writes no extraction row for one — by design.
405+
// Selecting on the extraction row alone re-queues every general proxy of
406+
// every known SPAC on every backfill, forever, each one re-paying the
407+
// segmentation and AI cost to conclude again that there is nothing there.
408+
// The resolved SECTION_NOT_FOUND trace is what makes the predicate
409+
// converge.
410+
await seedSpac(5);
411+
await seedFiling({ cik: 5, accession_number: "acc-def14a", form: "DEF 14A" });
412+
await new ExtractionDeadLetterRepo().recordResolved({
413+
extractor_id: "merger-proxy",
414+
accession_number: "acc-def14a",
415+
section_name: "merger",
416+
reason_code: "SECTION_NOT_FOUND",
417+
detail: "no merger / business-combination / PIPE section text",
418+
failed_extractor_version: "1.0.0",
419+
source_run_id: null,
420+
});
421+
422+
const descriptor = getBackfillDescriptor("merger-proxy")!;
423+
expect(await descriptor.filterTodo!(await descriptor.selectCandidates())).toEqual([]);
424+
});
401425
});
402426

403427
describe("25-15 descriptor", () => {
@@ -428,6 +452,25 @@ describe("25-15 descriptor", () => {
428452
expect(todo.map((c) => c.accession_number)).toEqual(["acc-15"]);
429453
});
430454

455+
it("skips a 20-F the classifier ignores", async () => {
456+
// 20-F routes to this extractor so an FPI CLOSE filing can record its
457+
// combination; an ORDINARY annual report classifies `ignore` and writes
458+
// nothing. Selecting it re-runs every annual report of every de-SPAC'd
459+
// foreign private issuer on every backfill — six 20-Fs is six filings
460+
// re-processed forever — so the shared predicate refuses it, while the
461+
// Form 15 beside it is still selected.
462+
await seedSpac(5);
463+
await seedFiling({ cik: 5, accession_number: "acc-20f", form: "20-F" });
464+
await seedFiling({ cik: 5, accession_number: "acc-15", form: "15-12G" });
465+
466+
const descriptor = getBackfillDescriptor("25-15")!;
467+
const candidates = await descriptor.selectCandidates();
468+
expect(candidates.map((c) => c.accession_number).sort()).toEqual(["acc-15", "acc-20f"]);
469+
470+
const todo = await descriptor.filterTodo!(candidates);
471+
expect(todo.map((c) => c.accession_number)).toEqual(["acc-15"]);
472+
});
473+
431474
it("re-selects a 25-NSE recorded as deregistration that is actually unit separation", async () => {
432475
await seedSpac(5);
433476
await new SpacReportWriter().recordIpo({

0 commit comments

Comments
 (0)