Skip to content

Commit 657f7ea

Browse files
authored
Merge pull request #317 from workglow-dev/claude/zen-albattani-alr17v-proxy-retraction
Never retract a proxy event on a merger-proxy run that reached no verdict
2 parents 6902b7f + e930f7f commit 657f7ea

10 files changed

Lines changed: 433 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,34 @@ Reclassification therefore runs in both directions and a replay demotes the deal
19181918
instead of leaving the old verdict standing. The delete is scoped to that one
19191919
accession, so it can only retract what a previous run of the same filing wrote.
19201920

1921+
**Only a verdict about the DOCUMENT moves the event, which is why the writer
1922+
takes a tri-state rather than a boolean** (`ProxyEventVerdict`:
1923+
`emit | retract | leave`, decided by `resolveProxyEventVerdict`). `runSection`
1924+
contains every model and transport failure as a dead letter and returns
1925+
normally, so "the model said this filing discloses no deal" and "the provider
1926+
throttled us" both arrive as an unset `extractedDeal`. Read as a boolean the
1927+
second retracted a `proxy` event an earlier successful run had recorded from
1928+
real evidence — and losing it takes the whole approval stage with it, since the
1929+
vehicle's next Form 25/15 inside the 90-day post-approval window then classifies
1930+
`deregistration` instead of `completed` and `recordDeregistration` deletes the
1931+
`completed` event: a genuinely de-SPAC'd vehicle recorded as a wind-up, from a
1932+
run that merely could not reach a model. So a general definitive statement
1933+
retracts only on `seeks_combination_approval === false` — deterministic, and
1934+
conjunctive with the deal, so it decides alone and keeps the recovery ceremony
1935+
working during a provider outage — or on a dead letter that IS an answer
1936+
(`SECTION_NOT_FOUND` / `MODEL_EMPTY`, `NO_DEAL_REASONS`). Everything else,
1937+
including `LOW_CONFIDENCE_ALL` and `UNVERIFIED_SOURCE_SPAN` (where the model did
1938+
return a deal and only its certainty or its citation failed), leaves the stream
1939+
untouched.
1940+
1941+
The deterministic verdict is recorded on an existing extraction row **even when
1942+
the run extracted nothing** (`SpacMergerExtractionRepo.recordApprovalVerdict`),
1943+
because the gate really was evaluated — it is a property of the document, not of
1944+
the model call. Left NULL, the backfill's null-verdict clause re-selects the
1945+
same filing on every sweep, which is what made the failure repeat rather than
1946+
happen once. No row is invented where none exists: every predicate downstream
1947+
reads an extraction row as "this proxy produced something".
1948+
19211949
No extractor version bump: the persisted extraction rows are unchanged and still
19221950
correct, and the derived event is rebuildable from the document with no model
19231951
call.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it } from "vitest";
8+
import { resolveProxyEventVerdict } from "./Form_DEFM14A.storage";
9+
10+
/** Defaults for a run that reached the model and extracted a deal. */
11+
const extracted = { extractedDeal: true, concludedNoDeal: false } as const;
12+
/** A run that read the section and concluded the filing discloses no deal. */
13+
const noDeal = { extractedDeal: false, concludedNoDeal: true } as const;
14+
/** A run that failed before reaching an answer (no model, throttle, catch-all). */
15+
const noAnswer = { extractedDeal: false, concludedNoDeal: false } as const;
16+
17+
describe("resolveProxyEventVerdict", () => {
18+
it("emits for a definitive merger statement on the form symbol alone", () => {
19+
for (const form of ["DEFM14A", "DEFM14C"]) {
20+
expect(resolveProxyEventVerdict({ form, ...noAnswer, seeksCombinationApproval: null })).toBe(
21+
"emit"
22+
);
23+
}
24+
});
25+
26+
it("never emits for a preliminary or revised statement", () => {
27+
for (const form of ["PREM14A", "PREM14C", "PRER14A", "DEFR14A", "PRE 14A"]) {
28+
expect(resolveProxyEventVerdict({ form, ...extracted, seeksCombinationApproval: true })).toBe(
29+
"retract"
30+
);
31+
}
32+
});
33+
34+
it("emits for a general definitive statement only on both pieces of evidence", () => {
35+
expect(
36+
resolveProxyEventVerdict({ form: "DEF 14A", ...extracted, seeksCombinationApproval: true })
37+
).toBe("emit");
38+
expect(
39+
resolveProxyEventVerdict({ form: "DEF 14A", ...noDeal, seeksCombinationApproval: true })
40+
).toBe("retract");
41+
expect(
42+
resolveProxyEventVerdict({ form: "DEF 14A", ...extracted, seeksCombinationApproval: false })
43+
).toBe("retract");
44+
});
45+
46+
it("leaves the event alone when the run reached no verdict about the document", () => {
47+
// The invariant: a failed extraction is not evidence that this filing is
48+
// not a merger proxy, so it must not delete an event an earlier run wrote.
49+
expect(
50+
resolveProxyEventVerdict({ form: "DEF 14A", ...noAnswer, seeksCombinationApproval: true })
51+
).toBe("leave");
52+
expect(
53+
resolveProxyEventVerdict({ form: "DEF 14C", ...noAnswer, seeksCombinationApproval: true })
54+
).toBe("leave");
55+
});
56+
57+
it("still retracts on a false approval verdict when the extraction failed", () => {
58+
// The approval gate is deterministic AND conjunctive, so a statement that
59+
// asks for no approval can never emit — with or without a model.
60+
expect(
61+
resolveProxyEventVerdict({ form: "DEF 14A", ...noAnswer, seeksCombinationApproval: false })
62+
).toBe("retract");
63+
});
64+
});

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
import { readFileSync } from "node:fs";
88
import { afterEach, beforeEach, describe, expect, it } from "vitest";
9+
import { globalServiceRegistry } from "workglow";
910
import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI";
1011
import { setupAllDatabases } from "../../../config/setupAllDatabases";
1112
import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo";
13+
import { FILING_REPOSITORY_TOKEN } from "../../../storage/filing/FilingSchema";
14+
import { getBackfillDescriptor } from "../../../task/forms/backfillDescriptors";
1215
import { SpacMergerExtractionRepo } from "../../../storage/spac/SpacMergerExtractionRepo";
1316
import { SpacRepo } from "../../../storage/spac/SpacRepo";
1417
import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter";
@@ -253,6 +256,138 @@ describe("processMergerProxy (e2e)", () => {
253256
expect(row?.status).toBe("deal_announced");
254257
});
255258

259+
/**
260+
* A provider error that is NOT a throttle: `isRateLimitError` would otherwise
261+
* make the extractor wait out real backoff sleeps before failing.
262+
*/
263+
function scriptExtractionFailure(): () => void {
264+
return registerFakeStructuredProvider([new Error("upstream provider failure")]).unregister;
265+
}
266+
267+
it("leaves a recorded proxy event standing when the re-run's extraction fails", async () => {
268+
// A failed model call is not a verdict about the filing. The approval
269+
// evidence (`seeks_combination_approval`) is deterministic and still reads
270+
// true here — only the extraction failed — so retracting would delete an
271+
// event an earlier run recorded from real evidence, and losing the `proxy`
272+
// event cascades: the vehicle's next Form 25/15 inside the post-approval
273+
// window stops reading as a completed de-SPAC.
274+
await seedSpacWithOpenDeal(130);
275+
cleanup = scriptMergerDeal();
276+
await runProxy(130, "130-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
277+
expect((await repo.getSpac(130))?.status).toBe("proxy");
278+
cleanup();
279+
280+
cleanup = scriptExtractionFailure();
281+
await runProxy(130, "130-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
282+
283+
const events = await repo.getEvents(130);
284+
expect(events.filter((e) => e.event_type === "proxy")).toHaveLength(1);
285+
const row = await repo.getSpac(130);
286+
expect(row?.status).toBe("proxy");
287+
expect(row?.proxy_date).toBe("2021-05-01");
288+
289+
// The failure is on the worklist — left for retry, not read as an answer.
290+
const entry = await new ExtractionDeadLetterRepo().get("merger-proxy", "130-def14a", "merger");
291+
expect(entry?.status).toBe("pending");
292+
});
293+
294+
it("retracts on the document verdict even when the extraction failed", async () => {
295+
// The other half of the same rule: `seeks_combination_approval` is derived
296+
// from the document with no model call, and the gate is conjunctive — a
297+
// statement asking for no approval can never emit, so a false verdict
298+
// retracts whether or not the model was reachable. That is what keeps the
299+
// recovery ceremony able to unwind a stale close during a provider outage.
300+
await seedSpacWithOpenDeal(131);
301+
cleanup = scriptMergerDeal();
302+
await runProxy(131, "131-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
303+
expect((await repo.getSpac(131))?.status).toBe("proxy");
304+
cleanup();
305+
306+
cleanup = scriptExtractionFailure();
307+
await runProxy(131, "131-def14a", "DEF 14A", "2021-05-01", submissionWithBody(EXTENSION_BODY));
308+
309+
const events = await repo.getEvents(131);
310+
expect(events.some((e) => e.event_type === "proxy")).toBe(false);
311+
const row = await repo.getSpac(131);
312+
expect(row?.status).toBe("deal_announced");
313+
expect(row?.proxy_date).toBeNull();
314+
});
315+
316+
it("leaves a recorded proxy event standing when no model could be resolved", async () => {
317+
// The same invariant one stage earlier: the section never reaches a model,
318+
// so the run has nothing to say about the document.
319+
await seedSpacWithOpenDeal(132);
320+
cleanup = scriptMergerDeal();
321+
await runProxy(132, "132-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
322+
expect((await repo.getSpac(132))?.status).toBe("proxy");
323+
cleanup();
324+
cleanup = undefined;
325+
326+
// No `model` argument, and no merger-proxy model id is registered here.
327+
const parsed = await Form_DEFM14A.parse("DEF 14A", submissionWithBody(APPROVAL_BODY));
328+
await processMergerProxy({
329+
cik: 132,
330+
file_number: "",
331+
accession_number: "132-def14a",
332+
filing_date: "2021-05-01",
333+
primary_doc: "proxy.htm",
334+
form: "DEF 14A",
335+
formMergerProxy: parsed,
336+
});
337+
338+
const entry = await new ExtractionDeadLetterRepo().get("merger-proxy", "132-def14a", "merger");
339+
expect(entry?.reason_code).toBe("MODEL_RESOLUTION_ERROR");
340+
expect((await repo.getEvents(132)).filter((e) => e.event_type === "proxy")).toHaveLength(1);
341+
expect((await repo.getSpac(132))?.status).toBe("proxy");
342+
});
343+
344+
it("records the gate verdict even when the run extracted nothing", async () => {
345+
// The backfill re-selects a general definitive proxy whose verdict is NULL.
346+
// A run that could not reach the model still evaluated the deterministic
347+
// gate, so it must record the verdict — otherwise the clause never
348+
// extinguishes and every sweep re-runs the same filing forever.
349+
await seedSpacWithOpenDeal(133);
350+
cleanup = scriptMergerDeal();
351+
await runProxy(133, "133-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
352+
cleanup();
353+
354+
// Model the pre-gate row the recovery ceremony targets: verdict never set.
355+
const extractions = new SpacMergerExtractionRepo();
356+
const before = await extractions.getByAccession("133-def14a");
357+
await extractions.save({ ...before!, seeks_combination_approval: null });
358+
359+
cleanup = scriptExtractionFailure();
360+
await runProxy(133, "133-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY));
361+
362+
const after = await extractions.getByAccession("133-def14a");
363+
expect(after?.seeks_combination_approval).toBe(true);
364+
// The extraction the earlier run persisted is untouched by the failed one.
365+
expect(after?.target_name).toBe("Acme Target Inc.");
366+
367+
// And the backfill's null-verdict clause no longer selects it.
368+
await globalServiceRegistry.get(FILING_REPOSITORY_TOKEN).put({
369+
cik: 133,
370+
accession_number: "133-def14a",
371+
form: "DEF 14A",
372+
primary_doc: "proxy.htm",
373+
file_number: "",
374+
filing_date: "2021-05-01",
375+
acceptance_date: "2021-05-01T00:00:00.000Z",
376+
report_date: "2021-05-01",
377+
film_number: null,
378+
primary_doc_description: null,
379+
size: null,
380+
is_xbrl: null,
381+
is_inline_xbrl: null,
382+
items: null,
383+
act: null,
384+
} as never);
385+
const descriptor = getBackfillDescriptor("merger-proxy")!;
386+
const candidates = await descriptor.selectCandidates();
387+
expect(candidates.map((c) => c.accession_number)).toContain("133-def14a");
388+
expect(await descriptor.filterTodo!(candidates)).toEqual([]);
389+
});
390+
256391
it("records the gate verdict only for the forms it governs", async () => {
257392
// The "M" forms decide on the symbol alone and must not pay a full-document
258393
// render, so their verdict stays null — which is also what the backfill

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

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { ExtractorRunRepo } from "../../../storage/versioning/ExtractorRunRepo";
2020
import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../../storage/versioning/ExtractorRunSchema";
2121
import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo";
2222
import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo";
23+
import type { DeadLetterReasonCode } from "../../../storage/dead-letter/ExtractionDeadLetterSchema";
2324
import { SpacRepo } from "../../../storage/spac/SpacRepo";
2425
import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter";
26+
import type { ProxyEventVerdict } from "../../../storage/spac/SpacReportWriter";
2527
import { SpacMergerExtractionRepo } from "../../../storage/spac/SpacMergerExtractionRepo";
2628
import { parseEdgarHtml } from "../../html/parseEdgarHtml";
2729
import { DocumentTreeSegmenter } from "../registration-statements/s1/DocumentTreeSegmenter";
@@ -57,6 +59,47 @@ const MERGER_SECTION = MERGER_PROXY_SECTION;
5759
*/
5860
const DEFINITIVE_PROXY_FORMS = new Set(["DEFM14A", "DEFM14C"]);
5961

62+
/**
63+
* The dead-letter reasons that ARE a verdict about the document: the merger
64+
* section is absent, or a model read it and found no deal. Every other reason
65+
* — an unresolved model, a throttle, a nonce mismatch, the
66+
* `MODEL_INVALID_OUTPUT` catch-all — reports that this run never reached an
67+
* answer. Low-confidence and unverified-span rows are deliberately absent too:
68+
* the model did return a deal there, and only its citation or its certainty
69+
* failed.
70+
*/
71+
const NO_DEAL_REASONS = new Set<DeadLetterReasonCode>(["SECTION_NOT_FOUND", "MODEL_EMPTY"]);
72+
73+
/**
74+
* Which way this run moves the accession's `proxy` event.
75+
*
76+
* Both writes require evidence about the DOCUMENT. `seeks_combination_approval`
77+
* is deterministic and decides on its own when false: the gate is conjunctive,
78+
* so a statement that asks shareholders to approve nothing can never emit, with
79+
* or without a deal — which is what lets the recovery ceremony unwind a stale
80+
* close while a provider is down. A true verdict still needs the deal, and
81+
* there the distinction matters: a run whose extraction FAILED knows nothing
82+
* about the filing, so it leaves the stream alone rather than deleting an event
83+
* an earlier run recorded. Retracting there loses the whole approval stage —
84+
* the vehicle's next Form 25/15 inside the post-approval window stops reading
85+
* as a completed de-SPAC — and the failure re-occurs on every sweep.
86+
*/
87+
export function resolveProxyEventVerdict(args: {
88+
readonly form: string;
89+
readonly extractedDeal: boolean;
90+
/** This run concluded the filing discloses no deal (not merely: it failed). */
91+
readonly concludedNoDeal: boolean;
92+
readonly seeksCombinationApproval: boolean | null;
93+
}): ProxyEventVerdict {
94+
if (DEFINITIVE_PROXY_FORMS.has(args.form)) return "emit";
95+
// Preliminary / revised statements never emit; retracting keeps a replay able
96+
// to clear an event some earlier generation of this code wrote for one.
97+
if (!GENERAL_DEFINITIVE_PROXY_FORMS.has(args.form)) return "retract";
98+
if (args.seeksCombinationApproval !== true) return "retract";
99+
if (args.extractedDeal) return "emit";
100+
return args.concludedNoDeal ? "retract" : "leave";
101+
}
102+
60103
export interface ProcessMergerProxyArgs {
61104
readonly cik: number;
62105
readonly file_number: string;
@@ -82,7 +125,9 @@ export interface ProcessMergerProxyArgs {
82125
* it on the form symbol alone, so it still advances `proxy_date` when the
83126
* merger section is absent or low-confidence and the section dead-letters; a
84127
* {@link GENERAL_DEFINITIVE_PROXY_FORMS} one emits it only when this run
85-
* extracted a deal AND the document asks shareholders to approve it.
128+
* extracted a deal AND the document asks shareholders to approve it. Which way
129+
* the event moves — including the case where this run reached no verdict at all
130+
* — is {@link resolveProxyEventVerdict}.
86131
*/
87132
export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<void> {
88133
const { cik, accession_number, form, filing_date, formMergerProxy } = args;
@@ -196,6 +241,10 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
196241
// Evidence that this filing IS a merger proxy, for the general definitive
197242
// forms whose symbol does not say so.
198243
let extractedDeal = false;
244+
// Whether this run answered the question at all. A section that never reached
245+
// the model is not a filing that discloses no deal, and only the second of
246+
// those may move the proxy event.
247+
let concludedNoDeal = false;
199248

200249
if (skipMergerSection) {
201250
// Nothing to extract, and nothing wrong — but the skip still needs a
@@ -221,6 +270,7 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
221270
failed_extractor_version: extractor_version,
222271
source_run_id: null,
223272
});
273+
concludedNoDeal = true;
224274
} else if (!model) {
225275
// No model: dead-letter the merger section but still emit the proxy event
226276
// (deterministic, definitive statements only) so the SPAC timeline advances.
@@ -243,7 +293,7 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
243293
signal: args.context?.signal,
244294
});
245295
try {
246-
await runSection<MergerDealRow>({
296+
const outcome = await runSection<MergerDealRow>({
247297
sectionName: MERGER_SECTION,
248298
text: mergerText === "" ? undefined : mergerText,
249299
notFoundDetail: "no merger / business-combination / PIPE section text",
@@ -309,26 +359,38 @@ export async function processMergerProxy(args: ProcessMergerProxyArgs): Promise<
309359
return 1;
310360
},
311361
});
362+
concludedNoDeal = outcome.status === "dead-lettered" && NO_DEAL_REASONS.has(outcome.reason);
312363
} catch (err) {
313364
const message = err instanceof Error ? err.message : String(err);
314365
await recordMergerProxyRun(false, message);
315366
throw err;
316367
}
317368
}
318369

319-
// Emit the proxy event (definitive only) + recompute/correlate + rebuild.
370+
// Emit / retract the proxy event + recompute/correlate + rebuild.
320371
try {
372+
// The gate verdict is deterministic from the document, so a run that
373+
// extracted nothing still has one to record on a row an earlier run wrote.
374+
// Left NULL, the backfill's null-verdict clause re-selects this filing on
375+
// every sweep instead of extinguishing itself.
376+
if (seeks_combination_approval !== null && !extractedDeal) {
377+
await new SpacMergerExtractionRepo().recordApprovalVerdict(
378+
accession_number,
379+
seeks_combination_approval
380+
);
381+
}
321382
await new SpacReportWriter().recordMergerProxy({
322383
cik,
323384
accession_number,
324385
filing_date,
325386
form,
326387
primary_document: args.primary_doc ?? null,
327-
emitProxyEvent:
328-
DEFINITIVE_PROXY_FORMS.has(form) ||
329-
(GENERAL_DEFINITIVE_PROXY_FORMS.has(form) &&
330-
extractedDeal &&
331-
seeks_combination_approval === true),
388+
proxyEvent: resolveProxyEventVerdict({
389+
form,
390+
extractedDeal,
391+
concludedNoDeal,
392+
seeksCombinationApproval: seeks_combination_approval,
393+
}),
332394
});
333395
} catch (err) {
334396
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)