Portal continuation: record CFPORTAL successions - #325
Conversation
3350af5 to
c248fb5
Compare
f1c85ad to
ee79c36
Compare
ee79c36 to
e556f7f
Compare
64469be to
4a2eac7
Compare
Republic is two rows in the portal directory because in 2018 the funding-portal licence moved from OpenDeal Inc. to its subsidiary OpenDeal Portal LLC. EDGAR records that: Form CFPORTAL Item 1 carries a Successions block naming the acquired portal and its SEC file number. The parser typed it Type.Unknown() and storage dropped it. Typing it also fixes its array shape — the parser derives array-ness from the schema, so as `unknown` a lone acquiredHistoryDetails arrived as an object and the shape a consumer saw depended on how many successions the filer declared. Each Y filing now writes portal_succession rows, keyed by accession, and sets portals.succeeded_by_cik on the PREDECESSOR. Resolution is by file number only: buildPortalFileNumberIndex maps every CFPORTAL filing's file_number to its CIK, which measures 1:1 across the whole 137-filer universe, and a number two filers share is dropped rather than resolved to one of them. acquiredFundingPortal is free text — one committed filing names a predecessor with no CIK in the index at all — so an unresolved claim is kept with a null predecessor_cik instead of guessed from the name. The pointer is set only when the resolved CIK differs from the filer's own. Three of the four Y answers in the universe are self-referential, a rename EDGAR handled by keeping the CIK, and retiring those would mark a live portal dead. That column is the only evidence an older registration stopped: `live` means "did not file CFPORTAL-W" and OpenDeal Inc. never filed one, so both Republic rows read live. The fixture is the ORIGINAL 2018 registration, not the latest amendment — the claim is made once, where the handover happens, and the 2025 amendment answers N. A sweep reading only the newest filing per portal would find nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmxeduePb4ZhekGjJ7jV2
4a2eac7 to
7f47581
Compare
|
CI is red on Rebased onto None of them are in this diff. Checking out Because CI runs Verified locally against the same base:
The fix is Generated by Claude Code |
The sweep logged a tally every 100 filings and never called `updateProgress`, so the CLI's live progress row sat at 0% for the whole run while the log lines tore through the rendered tree above it. Report per filing rather than per hundred: each one is a fetch plus a parse, so the events are far apart in wall-clock terms, and a block of a hundred is what made the bar look stalled. Count failures toward the denominator too. `processed` only incremented on success, so a failed filing advanced nothing and a sweep with any failure could never reach 100%. Every candidate is either skipped or attempted, so the fraction is (processed + failed + skipped) / total; failures are named in the message instead of only reaching stderr. `onProgress` is a callback rather than an `IExecuteContext` so `runExtractorBackfill` stays runnable from a test with an injected counter — the separation `processFiling` already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmxeduePb4ZhekGjJ7jV2
recordSuccessions read the predecessor's row and wrote it back outside portalWriteLock, which every other portal write holds. A concurrent sweep processing the predecessor's own CFPORTAL-W between that read and write had its live:false and its as_of reverted — the lost update savePortalAsOf exists to prevent. PortalRepo.setSucceededBy does the read-modify-write under the lock and touches only the pointer column, so it needs no as_of guard: the filing carrying a handover is normally OLDER than the predecessor's last filing, and guarding on that date would reject the very write it is for. It reports whether the predecessor had a row, so a claim that could not be linked warns instead of vanishing. Nothing re-derives it yet — the docstring no longer claims a later sweep will. Cooperative cancellation is re-thrown from the succession catch rather than logged as a warning, matching the rule the rest of the sweep follows. Backfill progress is echoed to stdout at whole percents when not a TTY: the live UI only renders on a TTY, so events alone left an unattended run silent for hours, which is what the per-hundred logging they replaced was for. A dry run reports 100% with its counts instead of returning at 0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmxeduePb4ZhekGjJ7jV2
Summary
Records the succession claims Form CFPORTAL already carries, so a funding portal that took over another's registration can be recognised. This is what makes Republic one portal rather than two.
Design spec:
prd/docs/superpowers/specs/2026-08-23-portal-family-and-continuation-design.md(merged; as-built notes in workglow-dev/prd#88).The problem
Republic appears twice in the portal directory:
liveIn 2018 the funding-portal licence moved from OpenDeal Inc. to its wholly owned subsidiary. EDGAR records that — Item 1's Successions block names the acquired portal and its SEC file number — but the parser typed
successionsasType.Unknown()and storage dropped it.Both rows read
livebecause OpenDeal Inc. never filed a CFPORTAL-W. Nothing inportalssaid the older registration stopped.What lands
successionsis typed. That also fixes its array shape: the parser derives array-ness from the schema, so asunknowna loneacquiredHistoryDetailsarrived as an object and the shape a consumer saw depended on how many successions the filer declared.portal_successiontable, keyed(accession_number, detail_index), append-only. A filing answeringNwrites nothing — the row states a succession, and every other filing already says there was none.portals.succeeded_by_cik, set on the predecessor, pointing forward to the surviving filer, throughPortalRepo.setSucceededBy.Three decisions, each measured
File number only, never the name.
buildPortalFileNumberIndexmaps every CFPORTAL-family filing'sfile_numberto its CIK. Measured over the whole funding-portal universe (137 filers harvested from EDGAR) that mapping is 1:1 — 137 distinct numbers, none shared — which is what makes it a join key rather than a hint. A number two filers share is dropped from the index instead of resolved to an arbitrary one of them.acquiredFundingPortalis free text, and one committed filing names a predecessor ("Silicon Prairie Holdings Inc.") with no CIK in the index at all, so an unresolved claim is kept with a nullpredecessor_cikrather than guessed from the name.normalizePortalFileNumbercompares both halves as integers — filers type7-00065and007-000012as readily as the padded form — and refuses a value with no-, since a bare7would otherwise match every portal at once.Only a different CIK sets the pointer. Three of the four
Yanswers in the entire universe are self-referential: a rename EDGAR handled by keeping the CIK (Silicon Prairie, Wunderfund). Those produce no duplicate registration, and treating one as a continuation would retire a filer that is still the same live portal.The fixture is the ORIGINAL 2018 registration. The claim is made once, in the filing that carries the handover — OpenDeal Portal LLC's 2018 CFPORTAL answers
Yand its 2025 amendment answersN. A sweep reading only the latest filing per portal finds nothing.The pointer write takes the per-CIK lock
recordSuccessionsoriginally read the predecessor's row and wrote it back outsideportalWriteLock, which every other portal write holds. A concurrent sweep processing the predecessor's own CFPORTAL-W between that read and write had itslive: falseand itsas_ofreverted — the lost updatesavePortalAsOfexists to prevent.PortalRepo.setSucceededBydoes the read-modify-write under the lock and touches only the pointer column.It carries no
as_ofguard, deliberately: the filing that states a handover is normally older than the predecessor's last filing, so guarding on filing date would reject the very write it is for.It also reports whether the predecessor had a row, so a claim that could not be linked warns rather than vanishing silently. Nothing re-derives it yet, and the docstring no longer claims a later sweep will — see the Known gaps in workglow-dev/prd#88.
Cooperative cancellation is re-thrown from the succession catch rather than logged as a warning, matching the rule the rest of the sweep follows.
Backfill progress reporting
runExtractorBackfillreported progress byconsole.logging every 100 filings and never calledupdateProgress, so the CLI's progress bar sat at 0% for the whole sweep while raw log lines tore through the live TTY render. It now emits through anonProgresscallback wired tocontext.updateProgress:Progress is echoed to stdout at whole percents when stdout is not a TTY. The live UI only renders on a TTY —
runWorkflowCliruns plainly when piped — so events alone left an unattended run (> backfill.log, nohup, CI) silent for hours, which is exactly what the per-hundred logging they replaced was for. Capped at 100 lines for a corpus-sized sweep, and only where nothing is drawing over them.onProgressis an optional callback rather than anIExecuteContext, keepingrunExtractorBackfillrunnable from a test with an injected counter — the same separationprocessFilingalready had.Verification
Against
mainat72b92cc:tsc— cleanbun run format-check— cleanbun x vitest run src/sec/forms/portal src/storage/portal src/task/forms src/config— 41 files, 285 tests passingVerified end to end against a real production database:
sec db setup, version ceremony to1.1.0, thensec extractor backfill CFPORTAL --forceover 817 CFPORTAL filings. Result — 11portal_successionrows across the universe, of which exactly one is a true continuation:The other ten all have
predecessor_cik = cik— renames EDGAR handled by keeping the CIK (MinnowCfunding, Razitall, Wunderfund, Silicon Prairie, WeVidIt). Without the self-reference guard every one of those portals would have been retired and dropped from the directory, which is the strongest confirmation the rule was right.Not included
Nothing in this repo reads
succeeded_by_cik; the consumer-side fold lands in sroussey/embarc#98. The portal family tier and thesec portal continuations/suggest-familiescommands are still unwired — and that last one is what would recover a claim whose predecessor row was not ingested when the handover filing was processed.https://claude.ai/code/session_01EBmxeduePb4ZhekGjJ7jV2