fix(rekey): scope the truncate scripts, and make curated aliases restorable - #285
Merged
Merged
Conversation
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
…j5s-rekey-ceremony
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.
…aude/keen-knuth-hxoj5s-rekey-ceremony
sroussey
changed the base branch from
claude/keen-knuth-hxoj5s-company-identity-keys
to
main
August 14, 2026 17:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 "
generateCompanyHashfolds … socompany_hash_idchanges for those names". No table storescompany_hash_id.canonical_companyis keyed by a UUID plusnormalized_name, produced bynormalizeCompanyName— unchanged.So an operator following the documented ceremony destroyed
canonical_company,canonical_company_alias,canonical_company_address,canonical_company_phoneandcompany_identity_link, none of which the normalizer change made stale — and with no rebuild path.sec resolve --kind company --allre-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_idgeneration, and the familynormalized_namekeys.observation_provenanceis scopedWHERE kind = 'person'for the same reason — itskind: "company"rows (underwriter and issuer observations) cite observations that survive. On Postgres that means moving it out of theTRUNCATElist into a scopedDELETEinside the same transaction.2. The re-extraction gates re-paid AI cost for filings nothing touched
extractor_runsandextraction_dead_letterwere cleared wholesale, so the forms sweep re-selected every filing — re-running8-Kredemption/LOI detection andmerger-proxyextraction, whose output the script never deleted. Both deletes are now scoped to a newPERSON_OBSERVING_EXTRACTOR_IDS(src/storage/versioning/extractorIds.ts), derived from theobservePersoncall 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
psqlagainst unqualified namesIts usage block listed
psql "$SEC_PG_URL" -f …truncate-identity-tier.sqlnext to the sqlite3 line while everyDELETE FROMis unqualified — so on a deployment whosesearch_pathlists a staging schema first, following the file's own instructions deletes the other schema's identity tier, irreversibly.The
psqlline is removed rather thanSET LOCAL search_pathadded: sqlite3 rejects that statement, and portability is this file's only reason to exist. Postgres users are pointed at the.postgres.sqlvariant (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_descriptionthey cannot be spared. But the only dump surface printedalias_canonical_id -> target_canonical_idand never the names, so after the wipe an operator holding that output could reconstruct nothing: the UUIDs no longer resolve.All four
alias-listcommands (person, company, sponsor-family, underwriter-family) now:--format tsv, the machine-readable export;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.tsreads both.sqlfiles 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 inSEC_STORAGE_REGISTRY; provenance is person-scoped; the portable file carries noSET LOCAL search_pathand nopsqlusage line while the Postgres one does pin the schema.Verified red against the pre-fix scripts: 7 of 10 failed, 10/10 pass after.
src/task/canonical/aliasExchange.test.tsruns 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 neuteringdisplayNamesto an empty map (3 of 6 fail).src/cli/groups/canonical.test.tsgains an end-to-end check thatalias-list --format tsvemits the headeralias-importreads.Verified
npx tsc --noEmitclean. Green:truncateIdentityTier.test.ts10/10,aliasExchange.test.ts6/6,canonical.test.ts18/18,commandsBoot.test.ts19/19,version.test.ts12/12,src/storage/company+src/resolver64/64.Note on
version.test.tsflakiness under loadWhile validating, runs of
src/cli/groupsproduced failures inversion.test.ts— a file this PR does not touch. Every failure across every run wasError: Test timed out in 15000ms, never an assertion failure, and the count tracked host load, which ranged 7–53 from an unrelated concurrent job:src/config,src/commands,src/cli/groups)version, 1resolve)version)version)Resolved by a like-for-like A/B:
version.test.tsrun alone against an unmodifiedorigin/mainworktree and against this branch, back to back at low load —Identical, including runtime. Consistent with the startup benchmark, taken interleaved to cancel load drift over the operation each failing test runs first:
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
secsubprocess boots — not a regression from this diff.🤖 Generated with Claude Code