Skip to content

Commit 974e372

Browse files
committed
Merge branch 'code-extractors'
2 parents d0941de + 84d5622 commit 974e372

2 files changed

Lines changed: 115 additions & 23 deletions

File tree

src/task/spac/ProcessSpacTimelineTask.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { setupAllDatabases } from "../../config/setupAllDatabases";
1414
import { SEC_DRY_RUN, SEC_RAW_DATA_FOLDER } from "../../config/tokens";
1515
import { ExtractionDeadLetterRepo } from "../../storage/dead-letter/ExtractionDeadLetterRepo";
1616
import { FILING_REPOSITORY_TOKEN } from "../../storage/filing/FilingSchema";
17+
import { SPAC_CANDIDATE_REPOSITORY_TOKEN } from "../../storage/spac/SpacCandidateSchema";
1718
import { SpacReportWriter } from "../../storage/spac/SpacReportWriter";
1819
import { SpacRepo } from "../../storage/spac/SpacRepo";
1920
import { ExtractorRunRepo } from "../../storage/versioning/ExtractorRunRepo";
@@ -63,6 +64,23 @@ async function seedFiling(
6364
});
6465
}
6566

67+
async function seedSpacCandidate(): Promise<void> {
68+
await globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN).put({
69+
cik: CIK,
70+
name: "Screened Acquisition Corp",
71+
current_sic: 6770,
72+
signal_sic_6770: true,
73+
signal_name_match: true,
74+
signal_renamed_from: null,
75+
first_reg_form: "S-1",
76+
first_reg_date: "2020-11-01",
77+
reg_while_spac_named: true,
78+
confidence: "high",
79+
identified_at: "2026-01-01T00:00:00.000Z",
80+
signal_filed_sic_6770: null,
81+
});
82+
}
83+
6684
async function seedSuccessfulRun(
6785
accession: string,
6886
form: string,
@@ -268,6 +286,15 @@ describe("ProcessSpacTimelineTask", () => {
268286
// dead-letter under `redemption` / `loi` as well as `8-K`. Counting triage
269287
// without naming those ids left the operator with a placeholder.
270288
await seedFiling("0000000000-26-000001", "8-K", "2021-01-04", "d8k.htm");
289+
await new SpacReportWriter().recordRegistration({
290+
cik: CIK,
291+
accession_number: "0000000000-26-000000",
292+
filing_date: "2021-01-01",
293+
form: "S-1",
294+
primary_document: "s1.htm",
295+
spac_name: "Gated SPAC",
296+
spac_sic: 6770,
297+
});
271298

272299
const proto = ProcessAccessionDocFormTask.prototype;
273300
const real = proto.execute;
@@ -485,6 +512,50 @@ describe("ProcessSpacTimelineTask", () => {
485512
expect(out.skipped).toBe(0);
486513
});
487514

515+
it("does not process gated 8-Ks or Form 15s while the issuer has no spac row", async () => {
516+
// `sync spacs` worklist IS high/medium candidates, so the handler's
517+
// `isSpacCandidate` warning fires on every milestone 8-K / Form 15 of a
518+
// false-positive operating company (VolitionRx, CIK 93314) that will never
519+
// mint a row. Date-order replay plus the repair pass already handle a real
520+
// SPAC: skip the gated extractors until the row exists, then pick them up.
521+
await seedSpacCandidate();
522+
await seedFiling("0000000000-26-000001", "8-K", "2011-03-01", "d8k.htm", "1.01");
523+
await seedFiling("0000000000-26-000002", "15-12B", "2011-04-01", null);
524+
525+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
526+
const spy = vi
527+
.spyOn(ProcessAccessionDocFormTask.prototype, "execute")
528+
.mockResolvedValue({ success: true });
529+
try {
530+
const out = await new ProcessSpacTimelineTask().run({ cik: CIK });
531+
532+
expect(spy).not.toHaveBeenCalled();
533+
expect(warn).not.toHaveBeenCalled();
534+
expect(out.matched).toBe(2);
535+
expect(out.skipped).toBe(2);
536+
expect(out.processed).toBe(0);
537+
} finally {
538+
warn.mockRestore();
539+
}
540+
});
541+
542+
it("repair-passes a never-processed 8-K dated before the S-1 that mints the row", async () => {
543+
// First-time replay (no success row) used to process the 8-K in the first
544+
// pass because shouldReplay said "unprocessed". Date order then ran it
545+
// before the S-1, dropped the milestone, and warned. The repair pass is
546+
// how gated filings wait for the row — including ones never processed.
547+
await seedFiling("0000000000-26-000001", "8-K", "2020-12-01", "d8k.htm", "5.07");
548+
await seedFiling("0000000000-26-000002", "S-1", "2021-01-04", "s1.htm");
549+
const spy = mockFormProcessor();
550+
551+
const out = await new ProcessSpacTimelineTask().run({ cik: CIK });
552+
553+
const accessions = spy.mock.calls.map((c) => c[0]?.accessionNumber);
554+
expect(accessions).toEqual(["0000000000-26-000002", "0000000000-26-000001"]);
555+
expect(out.matched).toBe(2);
556+
expect(out.skipped).toBe(0);
557+
});
558+
488559
it("reaches a fixpoint: a second invocation processes nothing", async () => {
489560
// The invariant every gated predicate must hold: processing a filing writes
490561
// the artifact the predicate keys on, so it leaves the selected set. This

src/task/spac/ProcessSpacTimelineTask.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ import { SecCliConfigurationError } from "../../config/EnvToDI";
1717
import { TypeSecCik } from "../../sec/submissions/EnititySubmissionSchema";
1818
import { EXTRACTION_DEAD_LETTER_REPOSITORY_TOKEN } from "../../storage/dead-letter/ExtractionDeadLetterSchema";
1919
import { type Filing, FILING_REPOSITORY_TOKEN } from "../../storage/filing/FilingSchema";
20+
import { SpacRepo } from "../../storage/spac/SpacRepo";
2021
import { COMPONENT_VERSION_REPOSITORY_TOKEN } from "../../storage/versioning/ComponentVersionSchema";
2122
import { ExtractorRunRepo } from "../../storage/versioning/ExtractorRunRepo";
2223
import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../storage/versioning/ExtractorRunSchema";
2324
import {
2425
formToExtractorId,
2526
isNonfatalTimelineExtractor,
27+
isSpacRowGatedExtractor,
2628
} from "../../storage/versioning/extractorIds";
2729
import { getActiveSlot } from "../../storage/versioning/getActiveSlot";
2830
import { VersionRegistry } from "../../storage/versioning/VersionRegistry";
@@ -116,15 +118,16 @@ export type ProcessSpacTimelineTaskOutput = Static<ReturnType<typeof OutputSchem
116118
* Serial and in date order, all three disappear — the state machine simply
117119
* replays history in the order it happened.
118120
*
119-
* Case 1 also needs a second, capped pass. The filings gated by a missing SPAC
120-
* row are selected by {@link loadGatedNoOpAccessions}, which answers the empty
121-
* set while the row is absent — and the S-1 that mints it is normally on this
122-
* very timeline, replayed moments after the set was computed. So the run that
123-
* creates the row is also the run whose gated 8-Ks were all filtered out of it.
124-
* The set is recomputed after the replay and whatever it still names, minus
125-
* what this invocation already processed, is replayed once more, serially, in
126-
* timeline order. One extra pass, never a loop: the gated predicates are
127-
* monotone, so a filing that is processed leaves the set.
121+
* Case 1 also needs a second, capped pass. Gated extractors are omitted from
122+
* the first pass while the `spac` row is missing: otherwise a candidate that
123+
* never mints (or an 8-K dated before the S-1) no-ops, records success, and
124+
* warns. {@link loadGatedNoOpAccessions} then selects them once the row exists,
125+
* including ones a prior sweep already no-op'd. The S-1 that mints the row is
126+
* normally on this very timeline, so the set is empty when first computed and
127+
* is recomputed after the replay. Whatever it still names, minus what this
128+
* invocation already processed, is replayed once more, serially, in timeline
129+
* order. One extra pass, never a loop: the gated predicates are monotone, so
130+
* a filing that is processed leaves the set.
128131
*
129132
* Concurrency belongs BETWEEN issuers, not within one: SPACs are independent and
130133
* the writer already serializes per-CIK via `withCikLock`. Run this task once
@@ -226,20 +229,38 @@ export class ProcessSpacTimelineTask extends Task<
226229
const activeVersions = await loadActiveExtractorVersions(timeline);
227230
const successfulKeys = await loadSuccessfulKeys(activeVersions);
228231
const gatedNoOpAccessions = await loadGatedNoOpAccessions(cik, timeline);
229-
const toProcess = timeline.filter(
230-
(f) =>
231-
f.form !== null &&
232-
filingMeetsDateFloor(f.filing_date, filedOnOrAfter) &&
233-
shouldReplaySpacFiling({
234-
form: f.form,
235-
items: f.items,
236-
cik,
237-
accession_number: f.accession_number,
238-
force,
239-
successfulKeys,
240-
gatedNoOpAccessions,
241-
})
242-
);
232+
// Gated extractors (8-K, merger-proxy, 25-15) no-op — and warn — when the
233+
// `spac` row is missing. `sync spacs` worklist *is* high/medium candidates,
234+
// so that warning fires on every milestone 8-K of a false-positive
235+
// operating company that will never mint a row. A real SPAC's S-1 is on
236+
// this timeline; skip the gated filings until it runs, then the repair
237+
// pass below picks them up. `loadGatedNoOpAccessions` is empty while the
238+
// row is absent, so it cannot be the skip.
239+
const hasSpacRow = (await new SpacRepo().getSpac(cik)) !== undefined;
240+
const toProcess = timeline.filter((f) => {
241+
if (f.form === null) return false;
242+
if (!filingMeetsDateFloor(f.filing_date, filedOnOrAfter)) return false;
243+
const extractorId = formToExtractorId(f.form);
244+
// `--force 8-K` / `--force redemption` is an explicit request to run the
245+
// gated handler anyway; `--force all` still waits so the S-1 can mint.
246+
if (
247+
!hasSpacRow &&
248+
force.kind !== "extractors" &&
249+
extractorId !== undefined &&
250+
isSpacRowGatedExtractor(extractorId)
251+
) {
252+
return false;
253+
}
254+
return shouldReplaySpacFiling({
255+
form: f.form,
256+
items: f.items,
257+
cik,
258+
accession_number: f.accession_number,
259+
force,
260+
successfulKeys,
261+
gatedNoOpAccessions,
262+
});
263+
});
243264
const skipped = timeline.length - toProcess.length;
244265

245266
// `--dry-run` already no-ops writes via ReadOnlyTabularStorage. Replaying

0 commit comments

Comments
 (0)