Skip to content

Commit e8663e8

Browse files
committed
fix(spac): require approval evidence before a general definitive proxy 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
1 parent a0cd1c7 commit e8663e8

13 files changed

Lines changed: 589 additions & 34 deletions

CLAUDE.md

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,7 +1695,7 @@ which no deal walk reads):
16951695
| `2.01` | `completed` | unconditional |
16961696
| `1.01` | `definitive_agreement` | the submission carries a merger-shaped EX-2 exhibit AND the event is not dated before the date floor |
16971697
| `1.02` | `terminated` | a deal was pending as of that filing, or the exhibits are merger-shaped |
1698-
| `5.07` | `vote` | the pending deal had a definitive-agreement or proxy date |
1698+
| `5.07` | `vote` | a deal was pending as of that filing AND it had a proxy date |
16991699

17001700
Item `8.01` carries **no lifecycle mapping at all**`mapItemCodesToSpacEvents`
17011701
falls through its `default: break` and writes nothing. A termination disclosed
@@ -1842,15 +1842,123 @@ consideration) and observes the target company (`relation: "merger-proxy:target"
18421842
correlates each extraction onto the matching `spac_deal` by filing-date window —
18431843
_deriving_ `target_name` / `target_cik` / `pipe_amount` (a later filing supersedes
18441844
an earlier one — definitive over preliminary, revised over definitive), which
1845-
retires the 8-K path's positional merge-preserve. Only the **definitive merger**
1846-
statements `DEFM14A` and `DEFM14C` emit the `proxy` event (→ `proxy_date` /
1847-
`status = proxy`): a consent deal (14C) has no `8-K 5.07` vote, so the definitive
1848-
14C is its only approval-stage signal. Preliminary (`PREM14A`/`PREM14C`) and revised
1849-
(`DEFR14A`/`PRER14A`) proxies are extraction-only. S-4 is deferred (newco-CIK linkage). Configure the
1845+
retires the 8-K path's positional merge-preserve. Preliminary (`PREM14A`/`PREM14C`)
1846+
and revised (`DEFR14A`/`PRER14A`) proxies are extraction-only. S-4 is deferred
1847+
(newco-CIK linkage). Configure the
18501848
model via `SEC_MERGER_PROXY_MODEL` (default `claude-sonnet-5`) and an optional
18511849
confidence floor via `SEC_MERGER_PROXY_CONFIDENCE_FLOOR` (falls back to the shared
18521850
`SEC_S1_CONFIDENCE_FLOOR` when unset).
18531851

1852+
#### Which statements emit the `proxy` event
1853+
1854+
The `proxy` event (→ `proxy_date` / `status = proxy`) is **two-tier**, and the
1855+
tiers differ in what counts as evidence:
1856+
1857+
- The **definitive merger** statements `DEFM14A` / `DEFM14C` emit it on the form
1858+
symbol alone — the symbol says the meeting is about a combination, so the
1859+
event still lands when the merger section is absent or low-confidence and the
1860+
section dead-letters. (A consent deal (14C) has no `8-K 5.07` vote, so the
1861+
definitive 14C is its only approval-stage signal.)
1862+
- The **general definitive** statements `DEF 14A` / `DEF 14C` — which is where
1863+
most SPACs actually vote their combination — emit it only on **two**
1864+
conjunctive pieces of document evidence: an extracted deal AND
1865+
`seeksCombinationApproval` (`proxies-information-statements/seeksCombinationApproval.ts`),
1866+
a deterministic scan for a numbered proposal item naming the filer's defined
1867+
`Business Combination Proposal`, or a request to approve/adopt the **agreement**
1868+
(business combination agreement / agreement and plan of merger / merger
1869+
agreement).
1870+
1871+
The extracted deal alone is not evidence, because an **extension** proxy recites
1872+
the announced combination at length: `S1_SECTIONS.BUSINESS_COMBINATION` accepts a
1873+
bare `The Business Combination` heading, so the section is found, the model
1874+
returns a target, and the filing looked exactly like a merger proxy. That is a
1875+
silent corruption, not a missing row — a `proxy` event OPENS a deal by itself in
1876+
`spacDealGrouping.ts`, which makes the vehicle's next item `5.07` a merger
1877+
`vote`, which makes any Form 25/15 inside the 90-day post-approval window a
1878+
`completed` de-SPAC, with `surviving_name` promoted onto `current_name`.
1879+
1880+
The gate is deliberately **deterministic rather than a model schema field**.
1881+
The failure costs are asymmetric: a false positive corrupts the primary answer
1882+
with no trace, while a false negative only degrades a fallback — a real close
1883+
files an Item 2.01, which maps to `completed` unconditionally. A model field
1884+
also could not repair existing rows without re-paying the AI bill, and making it
1885+
required would force an extractor version cycle. Same reasoning as the ownership
1886+
subtotal and risk-caption heading guards: enforce it, don't trust the prompt.
1887+
1888+
Two rules the patterns must keep, both measured over 348 real SIC-6770
1889+
`DEF 14A` / `DEF 14C` statements (9 merger proxies, 339 extension / annual /
1890+
other; the rule scores 9/9 recall at **0 false positives**):
1891+
1892+
- **Never match the term anywhere in the document.** Every extension proxy
1893+
carries "as if they had voted against a business combination proposal", and
1894+
many cross-reference the combination's own proposal in another filing. A
1895+
whole-document test for `business combination proposal` fired on 24 of the
1896+
348, **all** of them extension or annual meetings. Both patterns are therefore
1897+
line-shaped (≤ 300 chars) and the defined-term one is anchored at line start.
1898+
- **The approval object must be the AGREEMENT.** A bare
1899+
`to approve … business combination` is the standard extension wording — "to
1900+
approve an amendment … to extend the date by which the Company must consummate
1901+
a business combination".
1902+
1903+
There is deliberately no extension-exclusion term: a proxy asking for an
1904+
extension AND for approval of the combination is a genuine merger proxy and must
1905+
still emit.
1906+
1907+
Extraction is unchanged either way — the `spac_merger_extraction` row is written
1908+
whether or not the gate passes — and the verdict is recorded on it as
1909+
`seeks_combination_approval`. `NULL` means the gate was not evaluated: the row
1910+
predates it, or the form symbol alone decides (the "M" forms never pay the
1911+
full-document render). The backfill keys on that NULL, so it must stay
1912+
distinguishable from a recorded `false`.
1913+
1914+
**Retraction.** `recordMergerProxy` deletes a `proxy` event for the accession
1915+
when the caller now decides the filing is not approval-stage, mirroring the
1916+
sibling deletes in `recordDeregistration` / `recordUnitSplit` / `recordCompleted`.
1917+
Reclassification therefore runs in both directions and a replay demotes the deal
1918+
instead of leaving the old verdict standing. The delete is scoped to that one
1919+
accession, so it can only retract what a previous run of the same filing wrote.
1920+
1921+
No extractor version bump: the persisted extraction rows are unchanged and still
1922+
correct, and the derived event is rebuildable from the document with no model
1923+
call.
1924+
1925+
**Recovery ceremony.** Databases populated before the gate existed carry false
1926+
closes. Re-run the proxies, then let the listing-removal classifier re-derive
1927+
the Form 25/15 verdicts that were built on them:
1928+
1929+
```bash
1930+
sec extractor backfill merger-proxy # re-derives the verdict; retracts stale proxy events
1931+
sec extractor backfill 25-15 # repeat until it reports `processed 0`
1932+
```
1933+
1934+
The first re-selects exactly the general definitive proxies whose
1935+
`seeks_combination_approval` is still NULL, and extinguishes itself once each
1936+
has a verdict. The second is the fixpoint sweep documented above: `filterTodo`
1937+
re-derives each accession's kind against the live classifier, so a stale
1938+
`completed` on an earlier accession keeps a later one classifying `completed`
1939+
until the earlier one is corrected.
1940+
1941+
Find the affected rows first — every `proxy` event on a general definitive form,
1942+
and the closes standing on one:
1943+
1944+
```sql
1945+
SELECT cik, accession_number, event_date, form FROM spac_event
1946+
WHERE event_type = 'proxy' AND form IN ('DEF 14A','DEF 14C') ORDER BY cik, event_date;
1947+
1948+
SELECT p.cik, c.accession_number AS close_accession, c.form, c.event_date
1949+
FROM spac_event p JOIN spac_event c ON c.cik = p.cik AND c.event_type = 'completed'
1950+
WHERE p.event_type = 'proxy' AND p.form IN ('DEF 14A','DEF 14C')
1951+
AND c.form IN ('25','25/A','25-NSE','25-NSE/A','15-12B','15-12G','15-15D','20-F','20-F/A');
1952+
```
1953+
1954+
⚠️ Expect status **regressions** on real CIKs as the false closes unwind —
1955+
`completed` back to `searching` / `deal_announced`, and `surviving_name` /
1956+
`post_merger_*` / the `current_*` promotion dropping back to the `spac_*` mirror
1957+
(those five columns are derived strictly from a completed deal and are never
1958+
merged forward). That is the correction, not a loss: every change is captured in
1959+
`spac_history` / `ChangeLog`, and genuinely completed SPACs are re-filled by
1960+
`sec spac backfill-despac`.
1961+
18541962
A proxy ingested before its issuer's `spac` row exists (e.g. the S-1 lands later)
18551963
hits the known-SPAC gate and no-ops — recording a successful run, so the normal
18561964
unprocessed-run sweep never revisits it. `sec spac backfill-merger-proxies`
@@ -2141,7 +2249,9 @@ by default over all filings of its forms; extractors whose candidate set is
21412249
narrower add a descriptor entry (the `redemption` / `loi` sub-extractors select
21422250
known-SPAC trigger-item 8-Ks) and extractors whose recorded success can be a
21432251
gated no-op override `filterTodo` (`merger-proxy` keeps candidates lacking a
2144-
`spac_merger_extraction` row, since its known-SPAC gate records `success: true`).
2252+
`spac_merger_extraction` row, since its known-SPAC gate records `success: true`,
2253+
plus the general definitive proxies whose `seeks_combination_approval` verdict is
2254+
still NULL — a self-extinguishing clause, since the re-run records one).
21452255
The default needing-work predicate is a bulk anti-join against `extractor_runs`
21462256
at the active version, exported as `defaultFilterTodo` so a descriptor that only
21472257
WIDENS it does not restate it. The `redemption` / `loi` descriptors do exactly

src/sec/forms/proxies-information-statements/Form_DEFM14A.storage.e2e.test.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,45 @@ describe("processMergerProxy (e2e)", () => {
158158
expect(row?.target_name).toBe("Acme Target Inc.");
159159
});
160160

161+
/** The merger prose the stub model's source_span must verify against. */
162+
const MERGER_PROSE =
163+
"<h1>The Business Combination</h1>\n" +
164+
"<p>\n" +
165+
"Merge SPAC Inc., a blank check company, has entered into a business combination\n" +
166+
"with Acme Target Inc., a leading operating company in its sector. Upon the closing\n" +
167+
"of the business combination with Acme Target Inc., the post-combination company\n" +
168+
"will continue the business of Acme Target Inc.\n" +
169+
"</p>";
170+
171+
/**
172+
* A real DEF 14A combination vote: the notice enumerates the combination as a
173+
* ballot item, and the section the extractor reads sits below it. The two are
174+
* separate lines in a real filing — the segmenter's `The Business
175+
* Combination` heading is a section title, not a proposal — so the approval
176+
* evidence and the extracted deal genuinely come from different places.
177+
*/
178+
const APPROVAL_BODY =
179+
"<h1>Proposal No. 1 — The Business Combination Proposal</h1>\n" +
180+
"<p>To approve and adopt the Business Combination Agreement, dated as of March 1, 2021.</p>\n" +
181+
MERGER_PROSE;
182+
183+
/**
184+
* An extension proxy that RECITES the announced combination: the segmenter
185+
* finds the same business-combination section and the extractor returns the
186+
* same deal, but the only thing on this ballot is the charter extension.
187+
*/
188+
const EXTENSION_BODY =
189+
MERGER_PROSE +
190+
"\n<h1>Extension Amendment Proposal</h1>\n" +
191+
"<p>To amend the Company's charter to extend the date by which it must consummate\n" +
192+
"an initial business combination from May 1, 2021 to November 1, 2021.</p>";
193+
161194
it("emits a proxy event for a DEF 14A whose merger section yields a deal", async () => {
162195
// Most SPACs vote their combination on a plain DEF 14A, never a DEFM14A.
196+
// The heading is the ballot item, so the approval gate passes too.
163197
await seedSpacWithOpenDeal(120);
164198
cleanup = scriptMergerDeal();
165-
await runProxy(120, "120-def14a", "DEF 14A");
199+
await runProxy(120, "120-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
166200

167201
const events = await repo.getEvents(120);
168202
expect(events.filter((e) => e.event_type === "proxy")).toHaveLength(1);
@@ -172,6 +206,64 @@ describe("processMergerProxy (e2e)", () => {
172206
expect(row?.target_name).toBe("Acme Target Inc.");
173207
});
174208

209+
it("emits no proxy event for an extension DEF 14A that describes the announced combination", async () => {
210+
// The failure this gate exists to prevent. The merger section is present
211+
// and yields a deal, so the extracted-deal test alone passes — but the
212+
// meeting approves only the extension. A proxy event here opens a deal on
213+
// its own, which turns the next item 5.07 into a merger vote and makes any
214+
// Form 25/15 inside 90 days read as a completed de-SPAC.
215+
await seedSpacWithOpenDeal(124);
216+
cleanup = scriptMergerDeal();
217+
await runProxy(
218+
124,
219+
"124-def14a-ext",
220+
"DEF 14A",
221+
"2021-05-01",
222+
submissionWithBody(EXTENSION_BODY)
223+
);
224+
225+
const events = await repo.getEvents(124);
226+
expect(events.some((e) => e.event_type === "proxy")).toBe(false);
227+
// Extraction is unchanged: the row is still written, with the verdict.
228+
const extraction = await new SpacMergerExtractionRepo().getByAccession("124-def14a-ext");
229+
expect(extraction?.target_name).toBe("Acme Target Inc.");
230+
expect(extraction?.seeks_combination_approval).toBe(false);
231+
232+
const row = await repo.getSpac(124);
233+
expect(row?.status).toBe("deal_announced");
234+
expect(row?.proxy_date).toBeNull();
235+
});
236+
237+
it("a retracted proxy event demotes the deal on replay", async () => {
238+
// Reclassification runs in both directions: reprocessing the same
239+
// accession with a body the gate rejects must remove the event the earlier
240+
// run wrote, not leave the stale approval stage standing.
241+
await seedSpacWithOpenDeal(125);
242+
cleanup = scriptMergerDeal();
243+
await runProxy(125, "125-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
244+
expect((await repo.getSpac(125))?.status).toBe("proxy");
245+
246+
await runProxy(125, "125-def14a", "DEF 14A", "2021-05-01", submissionWithBody(EXTENSION_BODY));
247+
248+
const events = await repo.getEvents(125);
249+
expect(events.some((e) => e.event_type === "proxy")).toBe(false);
250+
const row = await repo.getSpac(125);
251+
expect(row?.proxy_date).toBeNull();
252+
expect(row?.status).toBe("deal_announced");
253+
});
254+
255+
it("records the gate verdict only for the forms it governs", async () => {
256+
// The "M" forms decide on the symbol alone and must not pay a full-document
257+
// render, so their verdict stays null — which is also what the backfill
258+
// keys on to re-select the general definitive proxies that need one.
259+
await seedSpacWithOpenDeal(126);
260+
cleanup = scriptMergerDeal();
261+
await runProxy(126, "126-defm", "DEFM14A");
262+
expect(
263+
(await new SpacMergerExtractionRepo().getByAccession("126-defm"))?.seeks_combination_approval
264+
).toBeNull();
265+
});
266+
175267
it("emits no proxy event for a DEF 14A extension proxy with no merger section", async () => {
176268
// The form symbol is not evidence: an extension vote is the common DEF 14A.
177269
// Its proposal heading matches none of the merger section patterns, so no
@@ -211,7 +303,7 @@ describe("processMergerProxy (e2e)", () => {
211303
it("a DEF 14A proxy event makes the following item 5.07 a merger vote", async () => {
212304
await seedSpacWithOpenDeal(123);
213305
cleanup = scriptMergerDeal();
214-
await runProxy(123, "123-def14a", "DEF 14A");
306+
await runProxy(123, "123-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
215307

216308
const form8K = await Form_8_K.parse("8-K", "<html/>");
217309
await processForm8K({

src/sec/forms/proxies-information-statements/Form_DEFM14A.storage.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { globalServiceRegistry, type IExecuteContext, type ModelConfig } from "workglow";
7+
import {
8+
globalServiceRegistry,
9+
renderMarkdown,
10+
type IExecuteContext,
11+
type ModelConfig,
12+
} from "workglow";
813
import { prefetchModel } from "../../../task/model/EnsureModelDownloadedTask";
914
import { buildEntityObserver } from "../../../resolver/buildEntityObserver";
1015
import { CanonicalCompanyRepo } from "../../../storage/canonical/CanonicalCompanyRepo";
@@ -33,7 +38,11 @@ import {
3338
resolveModelId,
3439
} from "../registration-statements/s1/mergerModel";
3540
import type { FormS1Parsed } from "../registration-statements/Form_S_1";
36-
import { MERGER_PROXY_OPTIONAL_FORMS } from "../../../storage/versioning/extractorIds";
41+
import {
42+
GENERAL_DEFINITIVE_PROXY_FORMS,
43+
MERGER_PROXY_OPTIONAL_FORMS,
44+
} from "../../../storage/versioning/extractorIds";
45+
import { seeksCombinationApproval } from "./seeksCombinationApproval";
3746

3847
const EXTRACTOR_ID = "merger-proxy";
3948
// Stays 1.0.0: no persisted data to re-extract, so the target_description
@@ -47,22 +56,6 @@ const MERGER_SECTION = "merger";
4756
*/
4857
const DEFINITIVE_PROXY_FORMS = new Set(["DEFM14A", "DEFM14C"]);
4958

50-
/**
51-
* General definitive statements. Most SPACs never file a `DEFM14A` at all —
52-
* across SIC 6770 filers a plain `DEF 14A` reaches 575 distinct SPACs against
53-
* `DEFM14A`'s 234 — so refusing these dropped the proxy stage for the majority
54-
* of vehicles, and with it every downstream rule anchored on `proxy_date` (the
55-
* 5.07 vote mapping, the post-approval listing-removal window).
56-
*
57-
* The form symbol alone is NOT evidence here: most filings on these forms are
58-
* annual meetings and charter-extension votes. They emit the event only when
59-
* this filing actually IS a merger proxy, evidenced by an extracted deal.
60-
* Preliminary (`PRE*`), revised (`DEFR14A`/`PRER14A`) and supplemental
61-
* (`DEFA14A`) statements stay out either way: only a definitive statement is an
62-
* approval-stage signal.
63-
*/
64-
const GENERAL_DEFINITIVE_PROXY_FORMS = new Set(["DEF 14A", "DEF 14C"]);
65-
6659
export interface ProcessMergerProxyArgs {
6760
readonly cik: number;
6861
readonly file_number: string;
@@ -88,7 +81,7 @@ export interface ProcessMergerProxyArgs {
8881
* it on the form symbol alone, so it still advances `proxy_date` when the
8982
* merger section is absent or low-confidence and the section dead-letters; a
9083
* {@link GENERAL_DEFINITIVE_PROXY_FORMS} one emits it only when this run
91-
* extracted a deal.
84+
* extracted a deal AND the document asks shareholders to approve it.
9285
*/
9386
export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<void> {
9487
const { cik, accession_number, form, filing_date, formMergerProxy } = args;
@@ -151,10 +144,17 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
151144

152145
// Segment; PARSE_ERROR dead-letters the merger section so a retry can resolve it.
153146
let byName: Map<S1SectionName, string>;
147+
// null = not evaluated. Rendering the whole document is worth paying for only
148+
// on the general definitive forms, whose symbol does not say the meeting is
149+
// about a combination; the "M" forms decide on the symbol alone.
150+
let seeks_combination_approval: boolean | null = null;
154151
try {
155152
const doc = parseEdgarHtml(formMergerProxy.html, `${form} ${accession_number}`);
156153
const sections = new DocumentTreeSegmenter().segment(doc);
157154
byName = new Map<S1SectionName, string>(sections.map((s) => [s.name, s.text]));
155+
if (GENERAL_DEFINITIVE_PROXY_FORMS.has(form)) {
156+
seeks_combination_approval = seeksCombinationApproval(renderMarkdown(doc));
157+
}
158158
} catch (err) {
159159
const message = err instanceof Error ? err.message : String(err);
160160
await deadLetters.record({
@@ -278,6 +278,7 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
278278
merger_consideration: deal.merger_consideration,
279279
confidence: deal.confidence,
280280
source_span: boundSourceSpan(deal.source_span),
281+
seeks_combination_approval,
281282
model_id,
282283
created_at: now,
283284
});
@@ -302,7 +303,9 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
302303
primary_document: args.primary_doc ?? null,
303304
emitProxyEvent:
304305
DEFINITIVE_PROXY_FORMS.has(form) ||
305-
(GENERAL_DEFINITIVE_PROXY_FORMS.has(form) && extractedDeal),
306+
(GENERAL_DEFINITIVE_PROXY_FORMS.has(form) &&
307+
extractedDeal &&
308+
seeks_combination_approval === true),
306309
});
307310
} catch (err) {
308311
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)