Skip to content

Fix the SPAC demotion self-defeat and the fabricated parent legal form - #291

Open
sroussey wants to merge 1 commit into
mainfrom
claude/keen-knuth-onotd9-spac-demotion-and-splits
Open

Fix the SPAC demotion self-defeat and the fabricated parent legal form#291
sroussey wants to merge 1 commit into
mainfrom
claude/keen-knuth-onotd9-spac-demotion-and-splits

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Two independent fixes in the S-1 / company-observation path, plus a doc correction.

M1 — the alreadyKnownSpac guard is defeated by the filing's own earlier pass

The header-SIC demotion never fires for a post-de-SPAC filing that had to be retried, because four pre-segmentation paths in processFormS1 call recordRegistration(baseReg):

line path
:339 no model available
:386 S-1MEF / F-1MEF
:409 HTML parse error
:442 Part-II-only amendment

recordRegistration appends a registration event and rebuilds the spac row (SpacReportWriter.ts:197), so getSpac(cik) is already defined by the time a retry-dead-letters run can finally read the prospectus. The guard could not distinguish a row this same accession minted on an earlier pass, so the demotion never ran.

Narrowed rather than removed:

const priorEvents = await new SpacRepo().getEvents(cik);
const alreadyKnownSpac = priorEvents.some((e) => e.accession_number !== accession_number);

The invariant being protected is that a CIK which once registered as a blank check stays a SPAC CIK — that requires evidence from a different accession; this filing is not evidence about itself. The spac row is derived from the append-only event log, so it always carries at least one event: this is a faithful narrowing of getSpac(cik) !== undefined, not a change of subject.

The early recordRegistration calls are deliberately kept. On the MEF and Part-II-only paths there is no prospectus to judge, so the header SIC is the only signal that accession will ever have; skipping the record would lose real SPACs whose only filing a sweep saw was a MEF.

Residual, unavoidable: a de-SPAC filer whose first seen filing was a MEF under a stale 6770 header still mints a row and still gates its later filings. A MEF is a cover page with nothing in it to judge.

Tested with the two-run shape that is the only one reproducing it — pass 1 with no model (asserts the row is minted and s1_classification reads sgml-header), pass 2 on the same accession with a model (asserts sgml-header-rejected) — plus a control seeding a prior event from a different accession and asserting no demotion. Confirmed the new test fails against the old guard and the control passes under both.

M2 — copyForm fabricates a legal form and mints a second canonical company

splitParentClause grafted the parent's trailing legal form onto the divisional name. Verified:

as filed observation name (before) normalizes to
Cantor Fitzgerald Securities, a division of Cantor Fitzgerald, L.P. Cantor Fitzgerald Securities LP Cantor Fitzgerald Securities LP
Cantor Fitzgerald Securities (filed plainly) Cantor Fitzgerald Securities

LP is a canonicalize form, not strip, so the fabricated suffix survives normalizeCompanyName — two canonical companies and two identity links for one firm. Same for LLC and Trust. Corp converged only by accident, because Corp happens to be stripped.

copyForm and trailingLegalForm are deleted and both split branches now record observationName: x. Copying only the strip forms would be a no-op on identity by construction and would still fabricate a name that was never filed, so nothing is copied at all. The parent is already recorded — in familyName and in source_context.family_name — and the observation's only job is to be the name as filed.

Two live call sites persist observationName as company_observations.name: Form_S_1.storage.ts:1209 (sponsors) and s1/offeringSections.ts:456 (underwriters).

⚠️ Existing rows do NOT self-heal

sec resolve --renormalize recomputes normalized_name from the stored name, and the stored name is the fabricated one — so re-normalizing changes nothing here. Historical convergence needs either re-extraction (AI cost) or a hand alias per affected pair.

Blast radius is bounded: 4b7ab4b landed within the last 24h, so the alias route is almost certainly cheaper. List the affected observations first:

-- SQLite
SELECT observation_id, extractor_id, accession_number, name,
       json_extract(source_context, '$.as_filed')    AS as_filed,
       json_extract(source_context, '$.family_name') AS family_name
FROM company_observations
WHERE source_context LIKE '%"family_name"%'
  AND instr(json_extract(source_context, '$.as_filed'), name) = 0
ORDER BY accession_number, observation_id;
-- Postgres
SELECT observation_id, extractor_id, accession_number, name,
       source_context::json->>'as_filed'    AS as_filed,
       source_context::json->>'family_name' AS family_name
FROM company_observations
WHERE source_context LIKE '%"family_name"%'
  AND position(name IN source_context::json->>'as_filed') = 0
ORDER BY accession_number, observation_id;

The instr/position clause is the test for a fabricated form: the stored name is not a substring of the name as filed precisely when a suffix the filer never wrote was appended. Each row it returns is one alias:

sec canonical company alias "Cantor Fitzgerald Securities LP" "Cantor Fitzgerald Securities"

LOW — pickLatestTrustFact doc/code disagreement

src/storage/spac/pickLatestTrustFact.ts:54-56 said "later (or equal) filed date wins" while the code is incoming.filed > existing.filed. The code is right — a 10-Q/A has a strictly later filed date, and an equal-filed re-write is churn — so only the prose changed.

Tests

  • splitParentClause.test.ts — the copy assertions inverted; the GP-copy case dropped; "does not copy a form when X already has one" kept as a now-trivially-true regression pin; new Cantor case asserting the observation name, that it normalizes equal to the plainly-filed name, and that familyName still carries Cantor Fitzgerald, L.P.; the same for LLC and Trust; parentClauseSourceContext still carrying as_filed + family_name.
  • Form_S_1.storage.underwriters.test.ts — the end-to-end split test asserted the fabricated Kingswood Capital Markets Inc; now asserts Kingswood Capital Markets, with the canonical family row assertions unchanged.
  • headerSicDowngrade.test.ts — the two-run reproduction and its control described above.
$ bun run test -- src/storage/company/splitParentClause.test.ts src/storage/spac/ \
    src/sec/forms/registration-statements/Form_S_1.storage.underwriters.test.ts \
    src/sec/forms/registration-statements/s1/spacSponsor.e2e.test.ts \
    src/sec/forms/registration-statements/s1/headerSicDowngrade.test.ts
 Test Files  18 passed (18)
      Tests  134 passed (134)

$ npx tsc --noEmit   # exit 0

Note

legalFormTrailingCanonical (src/util/legalForms.ts) now has no production consumer — splitParentClause was its only one. It is left exported and tested rather than removed: it is part of the one shared legal-form vocabulary, and deleting it here would collide with the separate prose-matcher change in #289.


Generated by Claude Code

M1 — the `alreadyKnownSpac` guard was defeated by the filing's own
earlier pass. FOUR pre-segmentation paths call `recordRegistration`
(no model, S-1MEF, parse error, Part-II-only amendment), each of which
appends an event and rebuilds the spac row, so `getSpac(cik)` was
already defined on the `retry-dead-letters` run that could finally read
the prospectus — and a post-de-SPAC filer under a stale 6770 header was
never demoted. The guard now reads the event log and requires evidence
from a DIFFERENT accession. The early `recordRegistration` calls stay:
on the MEF and Part-II-only paths the header SIC is the only signal that
accession will ever have.

M2 — `copyForm` grafted the parent's trailing legal form onto the
divisional name, fabricating a name that was never filed. On the
canonicalize-identity forms (LP, LLC, Trust) it survives
`normalizeCompanyName`, so "Cantor Fitzgerald Securities, a division of
Cantor Fitzgerald, L.P." minted a second canonical company beside the
same underwriter named plainly. `copyForm` and `trailingLegalForm` are
deleted; both split branches record X exactly as filed. The parent is
already carried in `familyName` and `source_context.family_name`.

LOW — pickLatestTrustFact's doc said "later (or equal) filed date wins"
while the code is a strict `>`. The code is right; the prose is fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz
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