Skip to content

Portal continuation: record CFPORTAL successions - #325

Merged
sroussey merged 4 commits into
mainfrom
claude/merged-portal-deduplication-8p60kl
Aug 25, 2026
Merged

Portal continuation: record CFPORTAL successions#325
sroussey merged 4 commits into
mainfrom
claude/merged-portal-deduplication-8p60kl

Conversation

@sroussey

@sroussey sroussey commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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:

CIK EDGAR name brand file number Form C deals live
1672732 OpenDeal Inc. Republic 007-00046 83 (2016–2019) 1
1751525 OpenDeal Portal LLC Republic 007-00167 547 (2019–2023) 1

In 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 successions as Type.Unknown() and storage dropped it.

Both rows read live because OpenDeal Inc. never filed a CFPORTAL-W. Nothing in portals said the older registration stopped.

What lands

  • successions is typed. That 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.
  • New portal_succession table, keyed (accession_number, detail_index), append-only. A filing answering N writes nothing — the row states a succession, and every other filing already says there was none.
  • New portals.succeeded_by_cik, set on the predecessor, pointing forward to the surviving filer, through PortalRepo.setSucceededBy.
  • Backfill progress reporting, a separate concern that surfaced while running this feature's own backfill.

Three decisions, each measured

File number only, never the name. buildPortalFileNumberIndex maps every CFPORTAL-family filing's file_number to 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. acquiredFundingPortal is 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 null predecessor_cik rather than guessed from the name.

normalizePortalFileNumber compares both halves as integers — filers type 7-00065 and 007-000012 as readily as the padded form — and refuses a value with no -, since a bare 7 would otherwise match every portal at once.

Only a different CIK sets the pointer. Three of the four Y answers 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 Y and its 2025 amendment answers N. A sweep reading only the latest filing per portal finds nothing.

The pointer write takes the per-CIK lock

recordSuccessions originally 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.

It carries no as_of guard, 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

runExtractorBackfill reported progress by console.logging every 100 filings and never called updateProgress, 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 an onProgress callback wired to context.updateProgress:

  • 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 the bar advances instead of jumping in blocks;
  • a failed filing no longer stalls the bar. The old counter only incremented on success, so a sweep with any failure could never reach 100%;
  • one emit before the first filing, so a sweep that skipped most of its candidates opens at that baseline instead of 0%; an empty candidate set reports 100%, and a dry run reports its counts rather than returning at 0%.

Progress is echoed to stdout at whole percents when stdout is not a TTY. The live UI only renders on a TTY — runWorkflowCli runs 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.

onProgress is an optional callback rather than an IExecuteContext, keeping runExtractorBackfill runnable from a test with an injected counter — the same separation processFiling already had.

Verification

Against main at 72b92cc:

  • tsc — clean
  • bun run format-check — clean
  • bun x vitest run src/sec/forms/portal src/storage/portal src/task/forms src/config — 41 files, 285 tests passing

Verified end to end against a real production database: sec db setup, version ceremony to 1.1.0, then sec extractor backfill CFPORTAL --force over 817 CFPORTAL filings. Result — 11 portal_succession rows across the universe, of which exactly one is a true continuation:

 1751525 | OpenDeal Inc. | 007-00046 | predecessor_cik 1672732 | 2018-09-04
 1672732 | OpenDeal Inc. | Republic  | live t | succeeded_by_cik 1751525

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 the sec portal continuations / suggest-families commands 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

@sroussey
sroussey changed the base branch from main to claude/embarc-dashboard-redesign-rrj5tp August 24, 2026 19:32
@sroussey
sroussey force-pushed the claude/merged-portal-deduplication-8p60kl branch from 3350af5 to c248fb5 Compare August 24, 2026 19:33
@sroussey sroussey changed the title Clean and dedupe ticker symbols in offerings and SPAC reports Get the branch build green: readonly-array push and two unformatted files Aug 24, 2026
@sroussey sroussey changed the title Get the branch build green: readonly-array push and two unformatted files Portal continuation: record CFPORTAL successions (plus two CI fixes) Aug 24, 2026
@sroussey
sroussey force-pushed the claude/merged-portal-deduplication-8p60kl branch from f1c85ad to ee79c36 Compare August 24, 2026 20:58
@sroussey sroussey changed the title Portal continuation: record CFPORTAL successions (plus two CI fixes) Portal continuation: record CFPORTAL successions Aug 24, 2026
@sroussey
sroussey force-pushed the claude/merged-portal-deduplication-8p60kl branch from ee79c36 to e556f7f Compare August 24, 2026 21:07
Base automatically changed from claude/embarc-dashboard-redesign-rrj5tp to main August 24, 2026 21:49
@sroussey
sroussey force-pushed the claude/merged-portal-deduplication-8p60kl branch 2 times, most recently from 64469be to 4a2eac7 Compare August 24, 2026 22:56
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
@sroussey
sroussey force-pushed the claude/merged-portal-deduplication-8p60kl branch from 4a2eac7 to 7f47581 Compare August 25, 2026 00:39

Copy link
Copy Markdown
Contributor Author

CI is red on format-check, and the failure is on main, not on this branch.

Rebased onto main at 72b92cc; head is now 7f47581. The build job fails at its first step with five unformatted files:

src/sec/forms/exempt-offerings/Form_D.storage.ts
src/task/forms/BackfillExtractorTask.ts
src/task/forms/ProcessAccessionDocFormTask.ts
src/task/forms/RetryDeadLettersTask.ts
src/task/spac/DownloadSpacCandidateDocsTask.ts

None of them are in this diff. Checking out origin/main directly and running bun run format-check reproduces the same five files and the same exit code, so this is a base-branch failure that every open PR will inherit. Three of the five are the files 72b92cc itself edited.

Because CI runs format-checkbuildtest cheapest-first, the failure masks the rest: build and test never execute, so the red says nothing about this change.

Verified locally against the same base:

  • tsc --noEmit — clean
  • bun x vitest run src/sec/forms/portal src/storage/portal src/config — 24 files, 187 tests passing

The fix is bun run format on main. I have not pushed it here, since it would pull five unrelated files into a portal-continuation PR. This branch should go green on the next run once main is formatted.


Generated by Claude Code

sroussey and others added 3 commits August 24, 2026 17:56
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
@sroussey
sroussey merged commit 0b3ecfe into main Aug 25, 2026
1 check passed
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