fix(spac): require approval evidence before a general definitive proxy emits a proxy event - #316
Merged
Conversation
…y emits a proxy event A `DEF 14A` / `DEF 14C` emitted the `proxy` lifecycle event on an extracted deal alone. `S1_SECTIONS.BUSINESS_COMBINATION` accepts a bare `The Business Combination` heading and the merger prompt only asks for the target and terms, so a charter-extension or annual proxy that recites the announced deal satisfied that test. The event then opens a deal by itself in `spacDealGrouping`, which makes the next item 5.07 a merger `vote`, which makes a Form 25/15 inside the 90-day post-approval window a `completed` de-SPAC — a false close with `surviving_name` promoted onto `current_name`, and no trace. Gate the event conjunctively on `seeksCombinationApproval`: a deterministic, line-shaped scan for a numbered proposal item naming the filer's defined Business Combination Proposal, or a request to approve/adopt the AGREEMENT. Measured over 348 real SIC-6770 DEF 14A/14C statements: 9/9 recall at 0 false positives. Extraction is unchanged; the verdict is recorded on the extraction row and `recordMergerProxy` retracts a stale event on replay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
…ata6g5-proxy-approval-gate Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
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.
The bug
Form_DEFM14A.storage.tsemitted theproxylifecycle event for a general definitive statement (DEF 14A/DEF 14C) wheneverextractMergerDealreturned a target above the confidence floor:An extracted deal is not evidence that the meeting approves the combination.
mergerDealInstructions()only asks the model to "identify the business-combination target and deal terms", andS1_SECTIONS.BUSINESS_COMBINATIONaccepts a bare^the business combination$heading — so a charter-extension or annual proxy that recites an already-announced deal satisfies the test in full.The failure chain, end to end
proxyevent opens a deal by itself inspacDealGrouping.ts(case "proxy": if (!open) open = openNew(e); open.proxy_date = e.event_date) — no prior Item 1.01 needed.spac8kMilestones.tsthen readspendingMerger = pending != null && pending.proxy_date != null, so the extension meeting's own Item 5.07 maps tovoteinstead ofeight_k.classifyListingRemoval.tscomputesapprovalDatefrom a non-nullproxy_datealone, so a Form 25 / 25-NSE / Form 15 filed withinLISTING_REMOVAL_MAX_DAYS_AFTER_APPROVAL(90 days) classifiescompleted.surviving_namederived and promoted ontocurrent_name,post_merger_*filled — recorded silently for a vehicle that is still searching.This is precisely the Zalatoris case the
proxy_daterequirement in CLAUDE.md exists to prevent. The existing negative e2e test used a body with no merger heading at all, so it never exercised this shape.The fix
A new
seeksCombinationApproval.tssupplies deterministic approval evidence, required conjunctively with the extracted deal. Extraction is untouched — thespac_merger_extractionrow is still written either way — and the verdict is persisted on it asseeks_combination_approval.Two line-shaped patterns (line ≤ 300 chars, whitespace-normalized):
Proposal No. 1 — The Business Combination Proposal, including theSPAC SHAREHOLDER PROPOSAL NO. 1andTHE PENSARE BUSINESS COMBINATION PROPOSALspellings real filers use — anchored at the start of the line after table/bullet markup;Deliberately excluded, and the JSDoc says why:
to approve … business combination— that is the standard extension wording ("to approve an amendment … to extend the date by which the Company must consummate a business combination"). The object of the approval has to be the agreement, not the combination as a concept.business combination proposal— see the measurement below.There is no extension/annual exclusion term: a proxy asking for an extension and for approval of the combination is a genuine merger proxy and must still emit.
Why deterministic rather than a model schema field
completedunconditionally.Measurement (STEP 0)
The committed corpus has exactly one proxy fixture (
defm14a_sample.txt, hand-authored), which cannot measure anything. I built a real labelled set instead.Corpus — 348 real EDGAR proxy statements (
DEF 14A/DEF 14C) from SIC-6770 filers, discovered through EDGAR full-text search on five deliberately rule-neutral queries ("business combination","Agreement and Plan of Merger","business combination agreement","annual meeting of stockholders","extension amendment") — vocabulary about agreements and meeting types, never about proposal headings. Every document was rendered through the repo's ownparseEdgarHtml+renderMarkdown, i.e. byte-for-byte what the gate reads in production.Labels — assigned by two signals independent of the rule: the statement annexes the merger / business combination agreement, and it contains
unaudited pro forma condensed combinedfinancials (present only when shareholders are voting on the combination itself). That yielded 9 positives / 339 negatives. All 9 positives were then hand-verified by reading their proposal lists (Chenghe, Chenghe I, Aimei Health, MedTech, CC Neuberger II, Investindustrial, TG Venture, Pensare, Cantor Equity Partners III). Negatives were spot-checked across the annex-only band (Churchill VI/VII, Golden Falcon, RF Acquisition, Inflection Point II, FutureTech II, Pure Acquisition) — every one a charter-extension vote.Result
The brief's starting set had to change, and the measurement is why. The proposed whole-text patterns
/\bbusiness\s+combination\s+proposal\b/iand/\bmerger\s+proposal\b/iscored 24 false positives out of 339, and every single one was an extension or annual meeting. Two shapes account for all of them:Making both patterns line-shaped cut that to 2 FPs; anchoring the defined-term pattern at the start of the line took it to 0. That anchoring is principled rather than fitted: a proposal item is a heading or a notice line, so the term begins the line. Both failing shapes are pinned as unit tests.
The line-shaped
approve/adopt the agreementpattern from the brief was kept unchanged and carries 8 of the 9 positives on its own with 0 FPs; the defined-term pattern was widened to allow the issuer's own name inside the term (THE **PENSARE** BUSINESS COMBINATION PROPOSAL), which is what recovers the 9th.Retraction semantics
SpacReportWriter.recordMergerProxynow deletes theproxyevent for the accession on theemitProxyEvent === falsebranch, mirroring the sibling deletes inrecordDeregistration/recordUnitSplit/recordCompleted. Reclassification therefore runs in both directions and a replay demotes the deal instead of leaving the old verdict standing. The delete is scoped to that one accession, so it can only retract what a previous run of the same filing wrote — a genuine merger proxy elsewhere in the CIK's stream keeps its event.Recovery ceremony
mergerProxyDescriptor.filterTodogains a second, self-extinguishing clause: form inGENERAL_DEFINITIVE_PROXY_FORMSAND an extraction row exists ANDseeks_combination_approvalis null. Membership is checked before the verdict column, in thespacTrigger8KDescriptorstyle.NULLtherefore means "the gate never ran here" and is load-bearing; a recordedfalseis a finished answer. Thedb setupadd-missing-column pass creates the column.Find the affected rows first:
No extractor version bump
Persisted extraction rows are unchanged and still correct; only the derived event changes, and it is rebuildable from the document with no model call.
Also corrected in CLAUDE.md
The 8-K item table stated the
5.07 → votecondition as "the pending deal had a definitive-agreement or proxy date". The code requires a proxy date only (pending.proxy_date != null) — which is exactly why a spuriousproxyevent is enough to fabricate a vote. Now documented as written.Tests
seeksCombinationApproval.test.ts(new, 11 cases) — true set covers the numbered heading, the agreement-adoption sentence, a notice of meeting, real TOC rows, and an extension proposal sitting beside a combination proposal. False set covers the real extension wording, an annual-meeting proposal list, a background paragraph reciting a pending combination, the trust-redemption boilerplate, and the cross-reference to another filing.Form_DEFM14A.storage.e2e.test.ts— the emittingDEF 14Acase now carries an approval-shaped ballot item above the same merger prose (deviation from the brief: the notice line and the segmenter'sThe Business Combinationsection heading are two separate lines, as they are in real filings, because the segmenter's heading pattern does not accept… Proposalas a section title); newemits no proxy event for an extension DEF 14A that describes the announced combination(asserts no event, extraction row still written withseeks_combination_approval === false, status staysdeal_announced); newa retracted proxy event demotes the deal on replay; newrecords the gate verdict only for the forms it governs. The DEFM14A/DEFM14C/PRE* cases are untouched by design.backfillDescriptors.test.ts—re-selects a general definitive proxy whose gate verdict was never recorded, plus the convergence assertionsdoes not re-select it once the verdict is recordedanddoes not re-select an M-form proxy, whose verdict is null by design.Verification
Full suite:
3674 passed | 5 failed | 20 skipped. All 5 failures are timeouts under parallel load, unrelated to this change: 3 insrc/cli/groups/version.test.ts(multi-spawn CLI subprocess tests) which pass in isolation, andparseOfferingTables.corpus.test.ts > loads committed S-1 fixtures, which sits on a timing cliff on this container — 27.6s on a stashed clean tree vs 28.9s with these changes, against a 30s limit — and passes in isolation both with and without the change.bunx tsc -p tsconfig.test.json --noEmitreports 123 errors, none of them in any file touched here (the suite is documented as red at ~122 and is not in CI).🤖 Generated with Claude Code
https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
Generated by Claude Code