Skip to content

fix(spac): make sec spac process reach a fixpoint - #314

Merged
sroussey merged 1 commit into
mainfrom
claude/sharp-lamport-ata6g5-spac-process-convergence
Aug 20, 2026
Merged

fix(spac): make sec spac process reach a fixpoint#314
sroussey merged 1 commit into
mainfrom
claude/sharp-lamport-ata6g5-spac-process-convergence

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

loadGatedNoOpAccessions selects the filings whose known-SPAC-gated handler
recorded a success while writing nothing, so sec spac process can repair them.
For that to terminate, every branch has to be monotone: processing a filing
must write the artifact the branch keys on, and so take it out of the set. Three
branches were not, and each re-selected the same filings on every run forever.

fdb3e1e's commit message claims that cost is "self-limiting: they then carry
an artifact or a dead-letter entry, so the next run skips them."
That claim is
false for all three.

1. 20-F — an FPI re-runs all six of its annual reports, forever

FORM_TO_EXTRACTOR_ID routes 20-F / 20-F/A to 25-15 so an FPI close
filing can record its combination. classifyListingRemoval answers "ignore"
for every ordinary annual 20-F, and (since b45ad0d) for every post-completion
one; processDeregistration then returns without writing. The 25-15 branch
selected any accession with no event, so a de-SPAC'd foreign private issuer with
six annual 20-Fs replays all six on every run, and gains one more each year.
backfillDescriptors.ts already got this right — the two had drifted.

A second, smaller instance in the same branch: processDeregistration returns
early on if (!args.filing_date), and a filing with no date was still selected.

2. 5.03-only 8-K

5.03 is in GATED_8K_ITEM_CODES via MILESTONE_ITEM_CODES, but
mapItemCodesToSpacEvents maps it to an event only when extractNameChange
finds a new registrant name in the narrative — and the narrative is non-null
only for a fetch escalated to the full submission, which
ProcessAccessionDocFormTask does for the redemption / LOI trigger items alone.
A 5.03-only 8-K therefore has no narrative by construction: it writes no event
on any run, and having no fullSubmissionText it runs neither detector, so it
leaves no dead letter either. Nothing it can produce would ever deselect it.

3. merger-proxy on the optional forms — the highest-volume one

The branch selected any merger-proxy accession with no spac_merger_extraction
row. But processMergerProxy deliberately writes nothing for a
MERGER_PROXY_OPTIONAL_FORMS filing whose merger section is absent
(skipMergerSection) — no row, no event, no dead letter, by design, because
most of those filings are annual and extension votes and dead-lettering each one
would bury the genuine failures. A plain DEF 14A reaches 575 distinct
SPACs
, so every ordinary annual/extension proxy of every one of them was
re-selected on every run. mergerProxyDescriptor.filterTodo carried the
identical predicate and the identical non-convergence.

Also fixed

Repairing a gated 8-K took two runs. ProcessSpacTimelineTask computed the
gated set before the replay, and that set is empty for a CIK with no spac
row — which is exactly the state this command exists to repair. The S-1 that
mints the row is normally on the same timeline and is replayed moments later, so
every gated 8-K had already been filtered out of the one run that could fix it. A
CIK with 58 gated 8-Ks reported skipped: 58 and an empty timeline, and only a
second, identical invocation repaired it — with nothing saying so. The set is now
recomputed after the replay, minus everything already sent to the processor, and
whatever remains gets one capped serial pass in timeline order. One pass is
provably enough for the row-minted-mid-run case once the three fixes above make
the set convergent; the cap means it cannot spin if a fourth non-convergent shape
is ever introduced.

Quadratic dead-letter scan. loadDetectorAccessions called
listAll("redemption") and listAll("loi") — every row of each extractor across
every CIK, materialized, memoized only within one loadGatedNoOpAccessions call,
i.e. per CIK. A batch over ~1,500 SPACs loaded ~60k rows per CIK. It now uses a
new ExtractionDeadLetterRepo.listByAccessions, built like the existing
listPendingByAccessions (chunked in-lists at MAX_IDS_PER_QUERY, no status
filter — a resolved row is the evidence), fed only the 8-K accessions that
survived the item-code and event tests.

Changes

  • processDeregistration.ts — new exported listingRemovalNeedsWork, the single
    predicate both spac process and sec extractor backfill 25-15 select on, so
    the two cannot drift again.
  • gatedNoOpAccessions.ts — 25-15 branch delegates to it; 5.03 dropped from
    the gated item codes (with the reason stated, since a future reader adding it
    back is the regression); merger-proxy branch gains the dead-letter conjunct;
    detector lookup scoped to the issuer's accessions.
  • Form_DEFM14A.storage.ts — the skipMergerSection branch records a RESOLVED
    SECTION_NOT_FOUND entry, via a new ExtractionDeadLetterRepo.recordResolved
    (one put, attempts: 0, first_seen_at preserved). The entry never reaches
    sec extractor dead-letters, which lists pending entries, so the worklist an
    operator reads is untouched; the volume is comparable to the auto-resolved
    MODEL_EMPTY rows the redemption / LOI detectors already write per trigger 8-K.
  • ProcessSpacTimelineTask.ts — replay loop factored into replayFilings, plus
    the capped repair pass. skipped is now counted against what was actually sent
    to the processor across both passes.

Invariant

Every predicate touched keys on an artifact the handler writes (event /
extraction row / dead-letter row) or on the classifier agreeing with what is
already recorded. Two "reaches a fixpoint: a second invocation processes nothing"-style tests hold that line; the seven new negative tests below each
fail on main.

This PR strictly reduces work and changes no data semantics: nothing is
selected that was not selected before, no extractor behaviour changes, and the
only new write is a resolved dead-letter row recording an outcome that was
previously left untraced.

Tests

New, all verified failing on main before the fix:

  • gatedNoOpAccessions.test.ts — annual 20-F, post-completion 20-F, 5.03-only
    8-K, optional-form proxy with a resolved SECTION_NOT_FOUND, and a scoping
    test that spies on the dead-letter storage and rejects any unfiltered
    { extractor_id }-only query. Plus two positives pinning that the exclusions
    are narrow: a 5.03,8.01 8-K and a proxy with no evidence at all are still
    selected.
  • ProcessSpacTimelineTask.test.ts — gated 8-K processed in the same run as the
    S-1 that mints the row; a second invocation processes nothing; no second pass
    under --dry-run.
  • Form_DEFM14A.storage.e2e.test.ts — the extension-proxy case now asserts the
    resolved entry exists and that listPending("merger-proxy") does not contain it.
  • backfillDescriptors.test.ts — general proxy with a legitimately absent merger
    section is not re-selected; a 20-F the classifier ignores is skipped while the
    Form 15 beside it is still selected.
$ bunx vitest run src/task/spac/ src/task/forms/backfillDescriptors.test.ts \
    src/sec/forms/exchange-listing-withdrawal/
 Test Files  16 passed (16)
      Tests  218 passed (218)

$ bun run format-check
All matched files use Prettier code style!

$ npx tsc --noEmit     # clean

Wider runs: src/task/ 481 passed / 15 skipped, src/storage/ +
src/sec/forms/miscellaneous-filings/ 969 passed. src/sec/ has one failure,
parseOfferingTables.corpus.test.ts > loads committed S-1 fixtures — a 30s test
timeout, reproduced on a clean main checkout and unrelated to this change.


🤖 Generated with Claude Code

https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT


Generated by Claude Code

`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
@sroussey
sroussey merged commit 4c344fa into main Aug 20, 2026
1 check passed
@sroussey
sroussey deleted the claude/sharp-lamport-ata6g5-spac-process-convergence branch August 20, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants