fix(spac): let a completion outrank a deregistration, and order the forms sweep - #282
Merged
Merged
Conversation
…orms sweep 1. A post-close Form 25 turned a completed de-SPAC into "liquidated". `deriveDeals` set `walkTerminal` unconditionally on the first liquidation/deregistration and broke the walk there. The `completed` event is dated by the 8-K's REPORT date while the Form 25 event is dated by its FILING date, so the routine delisting of the shell's units on the closing day routinely collides with — or sorts ahead of — the completion it follows. The walk then never reached the completion: the deal came out `terminated` and `buildSpacRow` reported `status: liquidated`, `failed_date` set, `completed_date` and `surviving_name` null. The failure branch is now skipped whenever the stream carries a completion anywhere, so the walk continues to it and the completion sets `walkTerminal` itself. A liquidation genuinely after a completion was already unreachable, so the guard cannot mask a real post-completion failure. The rollup needs no change — its `hasFailed` already keys on `deals.some(completed)`. 2. The forms sweep processed Form 25 before the S-1. The default form list is `Object.keys(FORM_TO_EXTRACTOR_ID)` and JS enumerates integer-like keys first, so the bare "25" ran fourth — long before the registration statement that mints the `spac` row `processDeregistration` is gated on. It returns silently and records a SUCCESSFUL run when no row exists, so the default anti-join never revisited it: on a freshly bootstrapped database every deregistration was dropped and liquidated SPACs kept a stale status and a null failed_date. `sortFormsForSweep` gives the sweep an explicit registration -> prospectus -> 8-K -> proxies -> 25/15 order, stable within a rank and with unranked forms after. Applied to explicit --form lists too. Deploy note: `ComputeFormsWorklistTask` resume state is an index into this list, so drain or restart in-flight `--shard` processes when deploying.
This was referenced Aug 14, 2026
sroussey
changed the base branch from
claude/keen-knuth-hxoj5s-8k-replay
to
main
August 14, 2026 17:22
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.
Stacked on #281 — base is
claude/keen-knuth-hxoj5s-8k-replay, notmain. Merge #281 first; GitHub will retarget this tomainautomatically. Both PRs touchspacDealGrouping.ts, in different functions, so stacking keeps the conflict at zero.Two Form 25 / deregistration defects.
Defect 1 (HIGH) — a deregistration dated at or before the completion turns a completed de-SPAC into "liquidated"
In
deriveDeals, theliquidation/deregistrationbranch setwalkTerminaland broke the event walk unconditionally. Events are ordered by(event_date, accession_number), and the two dates come from different clocks: thecompletedevent is dated by the 8-K's report date, while the Form 25 / 25-NSE event is dated by its filing date. So the routine post-close delisting of a de-SPAC'd shell's units routinely collides with — or sorts ahead of — the closing it follows.processDeregistrationwrites the event for any known SPAC with no check that a completion already exists.Concrete failure (verified by executing
deriveDeals)Events
[definitive_agreement 2022-01-10, deregistration 2022-06-16, completed 2022-06-21]— a 2.01 8-K whosereport_dateis absent soevent_datefalls back tofiling_date, plus the routine post-close Form 25 delisting the SPAC's units on the closing day. The walk ends at the deregistration and never reads the completion:deriveDeals→outcome: "terminated",outcome_date: "2022-06-16",source_accession: "0001-form25"buildSpacRow→status: "liquidated",failed_date: 2022-06-16,completed_date: null,surviving_name: nullA successfully de-SPAC'd company is reported as a liquidated shell. The same happens on an exact date tie whenever the Form 25's accession string sorts below the 8-K's.
Approach
Pre-scan the relevant events for a completion, and skip the failure branch entirely when one exists anywhere in the stream:
Deliberately not
break-ing: the walk continues and reaches the completed event, which setswalkTerminalitself. A liquidation genuinely after a completion was already unreachable (the completion breaks the walk first), so the guard cannot mask a real post-completion failure.spacRollup.tsneeds no edit — itshasFailedalready keys ondeals.some(d => d.outcome === "completed")and follows automatically.Rejected alternatives
completedahead of the failure types in the same-date sort. Perturbsdeal_indexassignment, which is a stability contract the existing testassigns the same deal_index regardless of event insertion orderguards. It also only fixes the exact-tie case, not the inverted-date one.processDeregistrationto skip writing the event when a completion exists. The Form 25 is a true fact and belongs in the append-only log. Only its interpretation is wrong, so only the interpretation moves.Defect 2 (MEDIUM) — the forms sweep processes Form 25 before the S-1, so a first-pass sweep drops every deregistration as a successful no-op
ComputeFormsWorklistTaskdefaults its form list toObject.keys(FORM_TO_EXTRACTOR_ID), and JS enumerates integer-like keys first in ascending numeric order. Executed, that yields["3","4","5","25","144","D",...]— the bare"25"runs fourth, well ahead of"S-1"(index 33),"424B4"(46) and"8-K"(48). ("25/A","25-NSE"and the 15-family are non-integer keys and already fall at the end, so only the bare 25 — the common form — is misordered.)processDeregistrationis known-SPAC gated: it returns silently and the run is recorded successful when nospacrow exists yet, so the defaultextractor_runsanti-join never revisits it.Concrete failure
sec update formson a freshly bootstrapped database: every issuer-filed Form 25 is processed before its S-1 has minted thespacrow, no deregistration event is written, and each filing is recorded successful. Liquidated SPACs keep a stalesearching/deal_announcedstatus and a nullfailed_dateindefinitely.Approach
Make the order explicit rather than incidental.
sortFormsForSweepinstorage/versioning/extractorIds.tsranks by a declaredSWEEP_PRIORITYof["S-1", "424", "8-K", "merger-proxy", "25-15"]— a comment states the dependency (registration/prospectus mints thespacrow; 8-K, proxies and 25/15 are gated on it). Unranked extractors (D, C, 3/4/5, 144, 1-A, …) follow, and the sort is stable within a rank soS-1precedesS-1/AprecedesDRS.ComputeFormsWorklistTaskapplies it to the filtered form list — including an explicit--formlist, so a multi-form request is ordered correctly without the operator knowing to do it. A form with no registered extractor is still filtered and warned about by the caller; the sort never drops one.Tests
src/storage/spac/spacDealGrouping.test.ts(newdescribe):does not terminate the deal when the deregistration is dated before the completion— the exact executed stream; assertscompleted,outcome_date 2022-06-21,source_accession= the 8-K. Failed before the fix withexpected 'terminated' to be 'completed'.does not terminate the deal on a same-date Form 25 whose accession sorts first— same failure.does not let a post-close liquidation event reopen or fail the completed deal— guard, passes before and after; proves the fix is scoped.src/storage/spac/spacRollup.test.ts:a post-close Form 25 leaves status completed with a null failed_date— assertsstatus: completed,failed_date: null,completed_date: 2022-06-21,surviving_namestill derived from the completed deal's target. It derives the deals throughderiveDealsrather than hand-writing them, because the rollup only readsdeals.some(completed)— a hand-written completed deal would make the test pass against the unfixed code and prove nothing. Failed before the fix withexpected 'liquidated' to be 'completed'.src/storage/versioning/formsSweepOrder.test.ts(new file):runs S-1 before 424 before 8-K before proxies before 25/15oversortFormsForSweep(Object.keys(FORM_TO_EXTRACTOR_ID)).keeps every form exactly once, so a newly wired form cannot be dropped— set-equality plus length.src/task/forms/formsSweep.test.ts:drains forms in sweep order, not object-key order— seeds one Form 25 and one S-1 filing, runs with no--form, asserts the emitted form column reaches S-1 before 25. Failed before the fix withexpected 1 to be less than 0.Every one of the four defect tests was written first and confirmed failing.
Verification
npx vitest run src/storage/spac→ 14 files, 104 tests passing.npx vitest run src/task/forms/formsSweep.test.ts src/storage/versioning/formsSweepOrder.test.ts→ 16 tests passing.npx vitest run src/storage/spac src/storage/versioning src/sec/forms/miscellaneous-filings src/task/forms src/task/spac src/commands→ 59 files, 488 tests, one failure:componentRegistry.test.ts > listRegisteredComponents returns one entry per extractor and resolverexpects 20 and gets 21. This is pre-existing onorigin/main— confirmed by checking out main's file contents and re-running it — and is untouched by this PR (EXTRACTOR_IDSalready has 17 entries against the test's stated 16). Not fixed here to keep the diff scoped.npx tsc --noEmitclean;bun run buildclean.Risk
deriveDealsreaders:SpacReportWriter.recomputeAndSaveDeals,sec spac report, and threespacDealGrouping.*test files. The change is a pure narrowing of one branch.ComputeFormsWorklistTaskresume state isformPos, an index into the form list — a shard resuming across a deploy that changes the order would resume at a different form. Drain or restart in-flight--shardprocesses when deploying this.Operator step (no code migration)
On any database bootstrapped before this fix, liquidated SPACs carry a stale status and a null
failed_date. Existing recovery works and just needs running:For SPACs whose deal was wrongly flipped to
terminatedby defect 1,deriveDealsonly re-runs on a write, so the corrected code needs a trigger. Either the #281 repair pass covers it, or use the cheaper targeted formsec extractor backfill 25-15 --force.🤖 Generated with Claude Code
Generated by Claude Code