Skip to content

sec: backfill legacy person_observations.titles into per-title rows - #217

Open
sroussey wants to merge 7 commits into
mainfrom
claude/beautiful-archimedes-vr1yck
Open

sec: backfill legacy person_observations.titles into per-title rows#217
sroussey wants to merge 7 commits into
mainfrom
claude/beautiful-archimedes-vr1yck

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #216. That PR moves person titles from a JSON-array column on person_observations to a dedicated person_observation_titles child table and switches every read path to join from the child. On any in-place DB upgrade past #216, existing observations' titles stay trapped in the old column and silently disappear from query surfaces.

This PR adds a db setup migration that copies each legacy row's titles array into the child table (empties dropped, case-insensitive dedupe, clamped to 256 chars) inside a single transaction, chunked (5000 rows) and progress-logged every 100k rows. It runs from setupAllDatabases immediately after the child-table setupDatabase() and covers both the SQLite and Postgres backends. The legacy titles column is left in place — SQLite cannot drop it without a full table rebuild, and no read path consults it any more.

The migration is idempotent: the child-table PK (observation_id, title) plus INSERT OR IGNORE / ON CONFLICT DO NOTHING means replays skip rows already present, and pre-existing child-table rows for the same observation are preserved rather than overwritten. Dry-run bails at the top (raw SQL reaches around the ReadOnlyTabularStorage wrapper).

Merge order

Merge #216 first, then this PR — or squash both together. If this PR merges first (before #216), the migration is a no-op: person_observations has no titles column and the SQLite / Postgres probes return cleanly.

Verification

Ran in /home/user/sec:

  • bun test src/storage/observation/PersonObservationTitlesLegacyMigration.test.ts — 5 pass, 0 fail (populated → migrated; empty legacy → no-op; fresh DB with no titles column → no-op; idempotent second run; pre-existing child row preserved).
  • bun test src/storage/observation/ — 29 pass, 0 fail across 6 files.
  • bun x tsc --noEmit -p . — clean.

Only the SQLite branch is unit-tested; the Postgres branch is implemented but not covered. Recommend a follow-up PG integration test once a Postgres fixture harness is convenient.


Generated by Claude Code

claude added 7 commits July 28, 2026 19:31
Replace the person_observation.titles array column with two tables:
person_observation_title (one row per single title per observation, replaced
wholesale on re-observation, reaped with the observation) and person_role
(canonical-tier tenures: person + company CIK + one canonical title with a
required start_date, optional end_date, and last_seen_date guard).

EntityObserver records dated role assertions for claims carrying filing_date,
source_filing_issuer_cik, and a role_scope population tag, canonicalizing
titles through normalizeManagementTitles (compound titles split into separate
tenures). Roster-complete filings — Form D related persons and the S-1
management section — close open tenures they no longer assert
(closeUnassertedPersonRoles), with strict date guards so out-of-order replays
are safe: an older roster never closes a newer assertion, a re-extraction that
finds a person re-opens the tenure its own accession closed, and a departure
and return yield two tenure rows.

person_role rows are resolver-versioned like the address/phone junctions:
purged by dropPrevious (person), rebuilt by extraction replays. New CLI
query 'sec query person-roles <cik> [--current]'; 'sec query persons' joins
titles from the child table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
- Re-opening a tenure absorbs interposed return tenures so an extractor-version
  re-run cannot leave two overlapping open rows for one (person, title).
- S-1 management closure is completeness-gated: sectionRunner now passes
  persist a meta.complete flag (true only when every extracted row survived
  the confidence floor and span verification), so a partial roster never
  records a false departure.
- closeUnasserted re-reads each tenure and re-evaluates the date guard under
  the per-tenure lock, so a concurrent newer assertion always wins; a same-day
  assertion tie re-opens a closure; an out-of-order earlier roster tightens a
  closed tenure's end_date back to the first non-asserting filing.
- A re-run of the accession that alone supported a tenure and no longer
  asserts it deletes the phantom row (equal-date self-retraction).
- Tenure match keys clamp to the stored 256-char width so overlong titles
  cannot mint duplicates.
- Form D roster closure requires at least one actually-observed person (a
  junk-only or all-entity related-persons list never mass-closes).
- Replace literal NUL bytes in key-separator strings with \\x00 escapes so
  the source files are no longer binary to git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
The DOM and node:stream/web ReadableStream types no longer overlap on a
fresh dependency install, so the direct cast fails tsc (TS2352) on every
branch. Route the cast through unknown, per the compiler's guidance — the
runtime object a fetch body provides is exactly what Readable.fromWeb
consumes. Pre-existing on the base branch; surfaced by CI's fresh install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
Roster-closure completeness: S-1 closure requires every extracted row to
survive filtering AND every persisted person to contribute an assertion (a
row whose titles all filter away marks the group incomplete); Form D closure
requires at least one observed person and no junk-dropped roster entry. A
closure call with no recorded assertions, or an empty filing date, is a
no-op — an empty asserted set must never read as 'everyone left'.

Tenure semantics: a seated board role now also asserts the implied Director
role, so gaining or losing a chairmanship never falsely closes a continuous
directorship (the canonical title form drops the redundant bare Director).
A second same-day accession asserting an open tenure is recorded as its
latest supporter, so a re-extraction of the first cannot retract a tenure a
sibling filing supports. The same-date reopen tie-break no longer erases a
recorded gap (it yields to interposed return tenures), and absorbed rows are
deleted only after the merged row is durable. Reaping now deletes tenures
whose only support was the reaped filing (deleteSoleSupport) — assert-only
scopes previously kept phantom roles forever. closeUnasserted counts only
open-to-closed transitions.

Mechanics: per-observation mutex + unchanged-list fast path + deleteSearch/
putBulk in the title repo; bulk delete in deleteForResolverVersion; parallel
title/name joins in the CLI queries; strict CIK validation on person-roles;
a resolver_version purge index (dropping the redundant company_cik one);
eager alias-repo resolution; internal assertion-key kept out of the barrel;
table-name doc fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
The title text is the row's identity within its observation — the same
precedent as the junction tables keying on the associated value — so the
storage layer now enforces one row per distinct title, and concurrent
writers converge on the same rows via the PK instead of relying solely on
the single-process mutex. title_index stays as a plain source-order column.

replaceForObservation becomes a per-title diff: an unchanged title keeps its
physical row across replays, a pure reorder only rewrites title_index in
place, and only titles the new list no longer asserts are deleted — which
also shrinks the mid-replacement crash window to genuinely removed titles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
Filings order titles inconsistently, so the ordinal was dead weight. The
title child row is now just its identity, (observation_id, title): a
reordered replay is a complete no-op, replacement inserts only titles not
already stored and deletes those no longer asserted, and reads sort by
title text for determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VQVkDKxGfax1k9qbr2JPU
An in-place DB upgrade past the person-title split leaves existing
observations' titles trapped in the old `titles` JSON-array column on
`person_observations`, while every read path now joins from the
`person_observation_titles` child table — so titles silently disappear
from query surfaces. This adds a `db setup` migration that copies each
legacy row's array into per-title rows (empties dropped, case-insensitive
dedupe, clamped to 256 chars) inside a single transaction, chunked and
progress-logged. The legacy column is left in place (SQLite cannot drop
it without a table rebuild) and the child-table PK plus INSERT OR IGNORE
/ ON CONFLICT DO NOTHING make the migration idempotent and safe when
prior title rows already exist.
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