Skip to content

fix(rekey): scope the truncate scripts, and make curated aliases restorable - #285

Merged
sroussey merged 6 commits into
mainfrom
claude/keen-knuth-hxoj5s-rekey-ceremony
Aug 14, 2026
Merged

fix(rekey): scope the truncate scripts, and make curated aliases restorable#285
sroussey merged 6 commits into
mainfrom
claude/keen-knuth-hxoj5s-rekey-ceremony

Conversation

@sroussey

@sroussey sroussey commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #280 — its decisions are what this PR's script headers assert (the company tier is not stale; the family key is final). Review/merge that one first.

Four corrections to the documented re-key ceremony. Each one costs an operator data or money today.

1. The scripts wiped the company canonical tier on a wrong rationale

The header justified it with "generateCompanyHash folds … so company_hash_id changes for those names". No table stores company_hash_id. canonical_company is keyed by a UUID plus normalized_name, produced by normalizeCompanyName — unchanged.

So an operator following the documented ceremony destroyed canonical_company, canonical_company_alias, canonical_company_address, canonical_company_phone and company_identity_link, none of which the normalizer change made stale — and with no rebuild path. sec resolve --kind company --all re-resolves from the company observations these scripts keep, so it can only rebuild links that were never stale.

That whole group is gone from both files, and the header now names the real stale set: the person normalized_* / person_hash_id generation, and the family normalized_name keys.

observation_provenance is scoped WHERE kind = 'person' for the same reason — its kind: "company" rows (underwriter and issuer observations) cite observations that survive. On Postgres that means moving it out of the TRUNCATE list into a scoped DELETE inside the same transaction.

2. The re-extraction gates re-paid AI cost for filings nothing touched

extractor_runs and extraction_dead_letter were cleared wholesale, so the forms sweep re-selected every filing — re-running 8-K redemption/LOI detection and merger-proxy extraction, whose output the script never deleted. Both deletes are now scoped to a new PERSON_OBSERVING_EXTRACTOR_IDS (src/storage/versioning/extractorIds.ts), derived from the observePerson call sites: S-1 D C CFPORTAL 1-A 1-Z 3 4 5 144. A test asserts the SQL and the constant cannot drift.

3. The portable script advertised psql against unqualified names

Its usage block listed psql "$SEC_PG_URL" -f …truncate-identity-tier.sql next to the sqlite3 line while every DELETE FROM is unqualified — so on a deployment whose search_path lists a staging schema first, following the file's own instructions deletes the other schema's identity tier, irreversibly.

The psql line is removed rather than SET LOCAL search_path added: sqlite3 rejects that statement, and portability is this file's only reason to exist. Postgres users are pointed at the .postgres.sql variant (which already pins the schema), with the reason stated in the comment. Enforced by test.

4. Aliases were destroyed with no way to restate them

Alias rows are hand-curated claims keyed by canonical UUIDs the wipe itself destroys — so unlike family_description they cannot be spared. But the only dump surface printed alias_canonical_id -> target_canonical_id and never the names, so after the wipe an operator holding that output could reconstruct nothing: the UUIDs no longer resolve.

All four alias-list commands (person, company, sponsor-family, underwriter-family) now:

  • print resolved display names alongside the ids;
  • accept --format tsv, the machine-readable export;
  • gain a matching sec canonical <kind> alias-import <file.tsv>.

TSV, not CSV, deliberately. These files hold names that routinely contain commas — Keefe, Bruyette & Woods, Inc., Frank R. Martire, Jr. — and the repo's only CSV reader (editorialImport.ts) splits on commas with no quoting, so a CSV export would be silently unreadable for exactly the names most worth restoring.

Import locates columns by header name (a reordered or trimmed file still works), resolves each pair by name, and reports a pair it cannot place without abandoning the rest — after a wipe, a name whose canonical row has not been re-extracted yet is an expected partial failure, not a reason to lose the other forty.

The two family groups now share one registration helper (familyAliasExchange.ts), so the export an operator takes before a wipe has the same shape on both — a wipe hits both at once.

Tests

src/storage/versioning/truncateIdentityTier.test.ts reads both .sql files as text and pins: the extractor-id list matches the constant; neither file names a company-tier table; the two variants name the same table set; every table named exists in SEC_STORAGE_REGISTRY; provenance is person-scoped; the portable file carries no SET LOCAL search_path and no psql usage line while the Postgres one does pin the schema.

Verified red against the pre-fix scripts: 7 of 10 failed, 10/10 pass after.

Note: it lives under src/ rather than beside the scripts because vitest's include is src/**/*.test.ts — a test in scripts/ would silently never run. Flagging in case you'd rather widen the include.

src/task/canonical/aliasExchange.test.ts runs the round trip end to end: curate an alias, export, wipe (alias rows and the canonical rows they cite), re-mint the canonicals under new ids, re-import from the export, assert the set is restored against the new ids. Plus the family-tier equivalent, orphan rendering, and the parser's header/missing-name behavior. Verified red by neutering displayNames to an empty map (3 of 6 fail).

src/cli/groups/canonical.test.ts gains an end-to-end check that alias-list --format tsv emits the header alias-import reads.

Verified

npx tsc --noEmit clean. Green: truncateIdentityTier.test.ts 10/10, aliasExchange.test.ts 6/6, canonical.test.ts 18/18, commandsBoot.test.ts 19/19, version.test.ts 12/12, src/storage/company + src/resolver 64/64.

Note on version.test.ts flakiness under load

While validating, runs of src/cli/groups produced failures in version.test.ts — a file this PR does not touch. Every failure across every run was Error: Test timed out in 15000ms, never an assertion failure, and the count tracked host load, which ranged 7–53 from an unrelated concurrent job:

host load failures
53 19 (across src/config, src/commands, src/cli/groups)
26 8 (7 version, 1 resolve)
11–16 3 (all version)
10–20 2 (all version)

Resolved by a like-for-like A/B: version.test.ts run alone against an unmodified origin/main worktree and against this branch, back to back at low load —

main    Test Files 1 passed (1)   Tests 12 passed (12)   0 timeouts   67.10s
branch  Test Files 1 passed (1)   Tests 12 passed (12)   0 timeouts   65.94s

Identical, including runtime. Consistent with the startup benchmark, taken interleaved to cancel load drift over the operation each failing test runs first:

db setup   main   1730 1893 1774 1981 1919 1967   mean 1877 ms
           branch 1738 1836 1831 1796 1572 1686   mean 1743 ms

No regression (the branch is marginally faster). An earlier sequential measurement of mine appeared to show a ~200 ms branch slowdown; that was an artifact of running all main samples before all branch samples, and the interleaved run refutes it.

So the failures were contention against a 15 s budget that is tight for tests spending ~3.6–3.9 s per assertion on sec subprocess boots — not a regression from this diff.


🤖 Generated with Claude Code

claude added 5 commits August 14, 2026 15:48
Three corrections to the re-key ceremony, all of which cost an operator
data or money today.

**The company canonical tier is not stale, and the scripts wiped it.**
The header justified the wipe with "generateCompanyHash folds … so
company_hash_id changes for those names" — but no table stores
`company_hash_id`. `canonical_company` is keyed by a UUID plus
`normalized_name`, produced by `normalizeCompanyName`, which did not
change. So the ceremony destroyed `canonical_company`,
`canonical_company_alias`, both company junctions and
`company_identity_link` for no reason, and with no rebuild path:
`sec resolve --kind company` re-resolves from the company observations
these scripts keep, so it can only rebuild links that were never stale
in the first place. That whole group is gone from both files, and the
header now names the real stale set — the person `normalized_*` /
`person_hash_id` generation and the family `normalized_name` keys.

`observation_provenance` is scoped `WHERE kind = 'person'` for the same
reason: its company-kind rows cite company observations that survive.
On Postgres that moves it out of the TRUNCATE list into a scoped DELETE
inside the same transaction.

**The re-extraction gates re-paid AI cost for filings nothing touched.**
`extractor_runs` / `extraction_dead_letter` were cleared wholesale, so
the forms sweep re-selected every filing — re-running 8-K
redemption/LOI detection and merger-proxy extraction, whose output the
script never deleted. Both deletes are now scoped to a new
`PERSON_OBSERVING_EXTRACTOR_IDS`, and a test asserts the SQL and the
constant cannot drift.

**The portable script advertised psql against unqualified names.** On a
deployment whose search_path lists a staging schema first, following its
own usage block deletes the WRONG schema's identity tier, irreversibly.
The psql line is removed rather than `SET LOCAL search_path` added:
sqlite3 rejects that statement and portability is the file's only reason
to exist. Postgres users are pointed at the `.postgres.sql` variant,
with the reason stated.

**Aliases were destroyed with no way to restate them.** Alias rows are
hand-curated claims keyed by canonical UUIDs the wipe destroys, so they
cannot be spared the way `family_description` is — but the only dump
surface printed `alias_canonical_id -> target_canonical_id` and never
the names, so an operator holding that output could reconstruct
nothing. All four `alias-list` commands now print resolved display
names, gain `--format tsv`, and gain a matching
`alias-import <file.tsv>`.

TSV, not CSV: canonical names routinely contain commas
(`Keefe, Bruyette & Woods, Inc.`) and the repo's only CSV reader splits
on commas with no quoting, so a CSV export would be unreadable for
exactly the names most worth restoring. Import locates columns by header
name and resolves each pair by NAME, reporting a pair it cannot place
without abandoning the rest — after a wipe, a name not yet re-extracted
is an expected partial failure.

The two family groups now share one registration helper, so the export
an operator takes before a wipe has the same shape on both.

New test coverage: `truncateIdentityTier.test.ts` reads both .sql files
and pins the extractor-id list, the absence of the company tables, the
two variants naming the same table set, every table existing in the
storage registry, the provenance scoping, and the portable file's
dialect. `aliasExchange.test.ts` runs the round trip end to end —
curate, export, wipe (canonical rows included), re-mint under new ids,
re-import, assert the set is restored against the NEW ids.

CLAUDE.md's re-keying section states the true stale set, the backend
split, the export-first warning, and the complete restore recipe: all
ten person-observing extractors, not just S-1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKvKnyVeQQQCm6FhtaLyMa
Two defects in the re-key ceremony, both found by executing it rather
than reading it.

**The Postgres variant is a syntax error and wipes nothing.**
`SET LOCAL search_path TO current_schema()` does not parse: SET takes
identifiers and string constants, never a function call. Verified
against PostgreSQL 16 — `ERROR: syntax error at or near "("`. It sits
inside the script's BEGIN, so every following statement aborts with
"current transaction is aborted" and the COMMIT rolls back: the whole
ceremony reports errors and deletes nothing. Failing closed is the good
half; the bad half is that the documented Postgres path did not work at
all, and the test read the file as text, so it asserted the presence of
the exact spelling Postgres rejects.

`SELECT set_config('search_path', current_schema(), true)` is the
expression-context spelling, with `true` supplying LOCAL. A test now
pins the accepted form and rejects the SET one by name.

**Scoping the re-extraction gates to persons stranded the family tier.**
Both scripts wipe `underwriter_link`, `underwriter_family_membership`
and `canonical_underwriter_family` for every extractor id, but the gates
were scoped to PERSON_OBSERVING_EXTRACTOR_IDS, which excludes `424` —
and the restore recipe dropped the `sec extractor backfill 424` line the
previous unscoped version relied on. `runOfferingSections` writes those
link rows from the priced 424B1/424B4 path under extractor id `424`, and
a family link row IS the attribution: no observation projection stands
behind it, and batch `sec resolve` refuses the family kinds. So every
424-sourced underwriter attribution was destroyed with nothing able to
rebuild it — the irreversible loss the family-tier note warns about.

The gates now read a separate `REKEY_REEXTRACT_EXTRACTOR_IDS` — the
person set plus `424`, named for what it means (the extractors whose
output these scripts delete) rather than for persons. `8-K`,
`merger-proxy`, `redemption` and `loi` stay out: nothing of theirs is
deleted, so clearing their runs would still re-pay AI cost for nothing.
A second test asserts the implication directly — a script that wipes
`underwriter_link` must re-extract `424` — so the two halves cannot
drift back apart.

All five new assertions fail against the previous SQL and pass after.
Full suite: 336 files, 2979 tests, green; `tsc --noEmit` clean.
@sroussey
sroussey changed the base branch from claude/keen-knuth-hxoj5s-company-identity-keys to main August 14, 2026 17:55
@sroussey
sroussey merged commit 6281ea0 into main Aug 14, 2026
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