Skip to content

Commit 6902b7f

Browse files
authored
Merge pull request #316 from workglow-dev/claude/sharp-lamport-ata6g5-proxy-approval-gate
fix(spac): require approval evidence before a general definitive proxy emits a proxy event
2 parents 4c344fa + f1b388d commit 6902b7f

13 files changed

Lines changed: 598 additions & 42 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
@@ -159,11 +159,45 @@ describe("processMergerProxy (e2e)", () => {
159159
expect(row?.target_name).toBe("Acme Target Inc.");
160160
});
161161

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

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

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

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

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

Lines changed: 20 additions & 19 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";
@@ -34,9 +39,11 @@ import {
3439
} from "../registration-statements/s1/mergerModel";
3540
import type { FormS1Parsed } from "../registration-statements/Form_S_1";
3641
import {
42+
GENERAL_DEFINITIVE_PROXY_FORMS,
3743
MERGER_PROXY_OPTIONAL_FORMS,
3844
MERGER_PROXY_SECTION,
3945
} from "../../../storage/versioning/extractorIds";
46+
import { seeksCombinationApproval } from "./seeksCombinationApproval";
4047

4148
const EXTRACTOR_ID = "merger-proxy";
4249
// Stays 1.0.0: no persisted data to re-extract, so the target_description
@@ -50,22 +57,6 @@ const MERGER_SECTION = MERGER_PROXY_SECTION;
5057
*/
5158
const DEFINITIVE_PROXY_FORMS = new Set(["DEFM14A", "DEFM14C"]);
5259

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

155146
// Segment; PARSE_ERROR dead-letters the merger section so a retry can resolve it.
156147
let byName: Map<S1SectionName, string>;
148+
// null = not evaluated. Rendering the whole document is worth paying for only
149+
// on the general definitive forms, whose symbol does not say the meeting is
150+
// about a combination; the "M" forms decide on the symbol alone.
151+
let seeks_combination_approval: boolean | null = null;
157152
try {
158153
const doc = parseEdgarHtml(formMergerProxy.html, `${form} ${accession_number}`);
159154
const sections = new DocumentTreeSegmenter().segment(doc);
160155
byName = new Map<S1SectionName, string>(sections.map((s) => [s.name, s.text]));
156+
if (GENERAL_DEFINITIVE_PROXY_FORMS.has(form)) {
157+
seeks_combination_approval = seeksCombinationApproval(renderMarkdown(doc));
158+
}
161159
} catch (err) {
162160
const message = err instanceof Error ? err.message : String(err);
163161
await deadLetters.record({
@@ -303,6 +301,7 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
303301
merger_consideration: deal.merger_consideration,
304302
confidence: deal.confidence,
305303
source_span: boundSourceSpan(deal.source_span),
304+
seeks_combination_approval,
306305
model_id,
307306
created_at: now,
308307
});
@@ -327,7 +326,9 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
327326
primary_document: args.primary_doc ?? null,
328327
emitProxyEvent:
329328
DEFINITIVE_PROXY_FORMS.has(form) ||
330-
(GENERAL_DEFINITIVE_PROXY_FORMS.has(form) && extractedDeal),
329+
(GENERAL_DEFINITIVE_PROXY_FORMS.has(form) &&
330+
extractedDeal &&
331+
seeks_combination_approval === true),
331332
});
332333
} catch (err) {
333334
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)