fix(spac): make sec spac process reach a fixpoint - #314
Merged
sroussey merged 1 commit intoAug 20, 2026
Conversation
`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
deleted the
claude/sharp-lamport-ata6g5-spac-process-convergence
branch
August 20, 2026 16:31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
loadGatedNoOpAccessionsselects the filings whose known-SPAC-gated handlerrecorded a success while writing nothing, so
sec spac processcan 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 carryan 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_IDroutes20-F/20-F/Ato25-15so an FPI closefiling can record its combination.
classifyListingRemovalanswers"ignore"for every ordinary annual 20-F, and (since b45ad0d) for every post-completion
one;
processDeregistrationthen returns without writing. The25-15branchselected 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.tsalready got this right — the two had drifted.A second, smaller instance in the same branch:
processDeregistrationreturnsearly on
if (!args.filing_date), and a filing with no date was still selected.2. 5.03-only 8-K
5.03is inGATED_8K_ITEM_CODESviaMILESTONE_ITEM_CODES, butmapItemCodesToSpacEventsmaps it to an event only whenextractNameChangefinds a new registrant name in the narrative — and the narrative is non-null
only for a fetch escalated to the full submission, which
ProcessAccessionDocFormTaskdoes 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
fullSubmissionTextit runs neither detector, so itleaves 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-proxyaccession with nospac_merger_extractionrow. But
processMergerProxydeliberately writes nothing for aMERGER_PROXY_OPTIONAL_FORMSfiling whose merger section is absent(
skipMergerSection) — no row, no event, no dead letter, by design, becausemost of those filings are annual and extension votes and dead-lettering each one
would bury the genuine failures. A plain
DEF 14Areaches 575 distinctSPACs, so every ordinary annual/extension proxy of every one of them was
re-selected on every run.
mergerProxyDescriptor.filterTodocarried theidentical predicate and the identical non-convergence.
Also fixed
Repairing a gated 8-K took two runs.
ProcessSpacTimelineTaskcomputed thegated set before the replay, and that set is empty for a CIK with no
spacrow — 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: 58and an empty timeline, and only asecond, 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.
loadDetectorAccessionscalledlistAll("redemption")andlistAll("loi")— every row of each extractor acrossevery CIK, materialized, memoized only within one
loadGatedNoOpAccessionscall,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 existinglistPendingByAccessions(chunkedin-lists atMAX_IDS_PER_QUERY, no statusfilter — a resolved row is the evidence), fed only the 8-K accessions that
survived the item-code and event tests.
Changes
processDeregistration.ts— new exportedlistingRemovalNeedsWork, the singlepredicate both
spac processandsec extractor backfill 25-15select on, sothe two cannot drift again.
gatedNoOpAccessions.ts— 25-15 branch delegates to it;5.03dropped fromthe 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— theskipMergerSectionbranch records a RESOLVEDSECTION_NOT_FOUNDentry, via a newExtractionDeadLetterRepo.recordResolved(one
put,attempts: 0,first_seen_atpreserved). The entry never reachessec extractor dead-letters, which lists pending entries, so the worklist anoperator reads is untouched; the volume is comparable to the auto-resolved
MODEL_EMPTYrows the redemption / LOI detectors already write per trigger 8-K.ProcessSpacTimelineTask.ts— replay loop factored intoreplayFilings, plusthe capped repair pass.
skippedis now counted against what was actually sentto 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 eachfail 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
mainbefore the fix:gatedNoOpAccessions.test.ts— annual 20-F, post-completion 20-F, 5.03-only8-K, optional-form proxy with a resolved
SECTION_NOT_FOUND, and a scopingtest that spies on the dead-letter storage and rejects any unfiltered
{ extractor_id }-only query. Plus two positives pinning that the exclusionsare narrow: a
5.03,8.018-K and a proxy with no evidence at all are stillselected.
ProcessSpacTimelineTask.test.ts— gated 8-K processed in the same run as theS-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 theresolved entry exists and that
listPending("merger-proxy")does not contain it.backfillDescriptors.test.ts— general proxy with a legitimately absent mergersection is not re-selected; a 20-F the classifier ignores is skipped while the
Form 15 beside it is still selected.
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 testtimeout, reproduced on a clean
maincheckout and unrelated to this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
Generated by Claude Code