Skip to content

fix(spac): require approval evidence before a general definitive proxy emits a proxy event - #316

Merged
sroussey merged 2 commits into
mainfrom
claude/sharp-lamport-ata6g5-proxy-approval-gate
Aug 20, 2026
Merged

fix(spac): require approval evidence before a general definitive proxy emits a proxy event#316
sroussey merged 2 commits into
mainfrom
claude/sharp-lamport-ata6g5-proxy-approval-gate

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

The bug

Form_DEFM14A.storage.ts emitted the proxy lifecycle event for a general definitive statement (DEF 14A / DEF 14C) whenever extractMergerDeal returned a target above the confidence floor:

DEFINITIVE_PROXY_FORMS.has(form) || (GENERAL_DEFINITIVE_PROXY_FORMS.has(form) && extractedDeal)

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", and S1_SECTIONS.BUSINESS_COMBINATION accepts 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

  1. A proxy event opens a deal by itself in spacDealGrouping.ts (case "proxy": if (!open) open = openNew(e); open.proxy_date = e.event_date) — no prior Item 1.01 needed.
  2. spac8kMilestones.ts then reads pendingMerger = pending != null && pending.proxy_date != null, so the extension meeting's own Item 5.07 maps to vote instead of eight_k.
  3. classifyListingRemoval.ts computes approvalDate from a non-null proxy_date alone, so a Form 25 / 25-NSE / Form 15 filed within LISTING_REMOVAL_MAX_DAYS_AFTER_APPROVAL (90 days) classifies completed.
  4. Result: a false de-SPAC — terminal status, surviving_name derived and promoted onto current_name, post_merger_* filled — recorded silently for a vehicle that is still searching.

This is precisely the Zalatoris case the proxy_date requirement 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.ts supplies deterministic approval evidence, required conjunctively with the extracted deal. Extraction is untouched — the spac_merger_extraction row is still written either way — and the verdict is persisted on it as seeks_combination_approval.

Two line-shaped patterns (line ≤ 300 chars, whitespace-normalized):

  • a numbered proposal item naming the filer's defined term — Proposal No. 1 — The Business Combination Proposal, including the SPAC SHAREHOLDER PROPOSAL NO. 1 and THE PENSARE BUSINESS COMBINATION PROPOSAL spellings real filers use — anchored at the start of the line after table/bullet markup;
  • a request to approve, adopt, or consider and vote upon the agreement: business combination agreement / agreement and plan of merger / merger agreement.

Deliberately excluded, and the JSDoc says why:

  • A bare 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.
  • Any whole-document test for 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

  • Failure costs are asymmetric. A false positive corrupts the primary answer with no trace; a false negative only degrades a fallback, since a real close files an Item 2.01 which maps to completed unconditionally.
  • A model field cannot repair existing rows without re-paying the AI bill on every already-processed proxy.
  • A required schema field forces an extractor version cycle. Nothing persisted changed, so none is needed.
  • The repo already applies "enforce it, don't trust the prompt" for the ownership-subtotal and risk-caption heading guards.

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 own parseEdgarHtml + 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 combined financials (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

rule TP FN FP TN
shipped (2 line-shaped patterns) 9/9 0 0 339

The brief's starting set had to change, and the measurement is why. The proposed whole-text patterns /\bbusiness\s+combination\s+proposal\b/i and /\bmerger\s+proposal\b/i scored 24 false positives out of 339, and every single one was an extension or annual meeting. Two shapes account for all of them:

  • the trust boilerplate every extension proxy carries — "a pro rata portion of the funds available in the trust account, as if they had voted against a business combination proposal";
  • a cross-reference to a proposal in a different filing — Pure Acquisition Corp's extension proxy says "see the section entitled 'Proposal No. 1—The Business Combination Proposal' located in the Registration Statement".

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 agreement pattern 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.recordMergerProxy now deletes the proxy event for the accession on the emitProxyEvent === false branch, mirroring the sibling deletes in recordDeregistration / 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.filterTodo gains a second, self-extinguishing clause: form in GENERAL_DEFINITIVE_PROXY_FORMS AND an extraction row exists AND seeks_combination_approval is null. Membership is checked before the verdict column, in the spacTrigger8KDescriptor style. NULL therefore means "the gate never ran here" and is load-bearing; a recorded false is a finished answer. The db setup add-missing-column pass creates the column.

sec extractor backfill merger-proxy   # re-derives the verdict; retracts stale proxy events
sec extractor backfill 25-15          # repeat until it reports `processed 0`

Find the affected rows first:

SELECT cik, accession_number, event_date, form FROM spac_event
WHERE event_type = 'proxy' AND form IN ('DEF 14A','DEF 14C') ORDER BY cik, event_date;

SELECT p.cik, c.accession_number AS close_accession, c.form, c.event_date
FROM spac_event p JOIN spac_event c ON c.cik = p.cik AND c.event_type = 'completed'
WHERE p.event_type = 'proxy' AND p.form IN ('DEF 14A','DEF 14C')
  AND c.form IN ('25','25/A','25-NSE','25-NSE/A','15-12B','15-12G','15-15D','20-F','20-F/A');

⚠️ Operators will see status REGRESSIONS on real CIKs as the false closes unwind — completed back to searching / deal_announced, and surviving_name / post_merger_sic / post_merger_tickers / the current_* promotion collapsing back to the spac_* mirror (those five are derived strictly from a completed deal and are never merged forward). This is intended. Every change is captured in spac_history / ChangeLog, and genuinely completed SPACs are re-filled afterwards by sec spac backfill-despac.

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 → vote condition 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 spurious proxy event 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 emitting DEF 14A case now carries an approval-shaped ballot item above the same merger prose (deviation from the brief: the notice line and the segmenter's The Business Combination section heading are two separate lines, as they are in real filings, because the segmenter's heading pattern does not accept … Proposal as a section title); new emits no proxy event for an extension DEF 14A that describes the announced combination (asserts no event, extraction row still written with seeks_combination_approval === false, status stays deal_announced); new a retracted proxy event demotes the deal on replay; new records the gate verdict only for the forms it governs. The DEFM14A/DEFM14C/PRE* cases are untouched by design.
  • backfillDescriptors.test.tsre-selects a general definitive proxy whose gate verdict was never recorded, plus the convergence assertions does not re-select it once the verdict is recorded and does not re-select an M-form proxy, whose verdict is null by design.

Verification

$ bunx vitest run src/sec/forms/proxies-information-statements/ src/task/forms/
 RUN  v4.1.11

 Test Files  21 passed (21)
      Tests  124 passed (124)
   Duration  19.23s

$ bun run format-check
$ prettier --check .
Checking formatting...
All matched files use Prettier code style!

$ bunx tsc --noEmit -p tsconfig.json
exit=0

Full suite: 3674 passed | 5 failed | 20 skipped. All 5 failures are timeouts under parallel load, unrelated to this change: 3 in src/cli/groups/version.test.ts (multi-spawn CLI subprocess tests) which pass in isolation, and parseOfferingTables.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 --noEmit reports 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

claude added 2 commits August 20, 2026 09:32
…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
@sroussey
sroussey merged commit 6902b7f into main Aug 20, 2026
1 check passed
@sroussey
sroussey deleted the claude/sharp-lamport-ata6g5-proxy-approval-gate branch August 20, 2026 17:01
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