fix/dup-repair
needs attentionviewing older commitd513853 · incrementalPR #306reviewed 2026-07-16 19:13 UTC0H · 2M · 3L · 4I- Purpose
- One-shot data remediation to reverse the June-2026 CFE cross-account contamination damage (#231) and the bills[0] stale-evidence inversion bug (fixed upstream in PR #298). ~67 contamination twin pairs and ~18 inverted replacements in prod need surgical repair.
- Goal
- Repair all duplicate-contract pairs: flip evidence holder to active, demote deposed (terminate or soft-delete depending on verdict), transfer site/subscription attachments per the agreements model, sweep 12 residual foreign bills.
- Sub-goals
- SG-1: FLIP_KEEP_HISTORY — holder becomes active, deposed terminates as history chapter; site links COPIED to holder, chapter keeps its own
- SG-2: FLIP_DELETE_TWIN / REACTIVATE_NO_EVIDENCE — deposed soft-deleted; all attachments MOVED to holder
- SG-3: Foreign-bill sweep — delete 12 xml bills whose embedded RPU disagrees with their contract + write S3 manifest
- What
- Refined the site-link (SUC) transfer semantics: FLIP_KEEP_HISTORY now COPIES links to the holder while keeping the terminated chapter's own links (so its bills remain visible in Recibos/exports). Subscription and entity_relationship rows still MOVE in both paths. Dry-run display updated to reflect the copy vs move distinction.
- Why
- Prior implementation moved all SUC links unconditionally, which would have stripped the terminated history chapter of its site associations — breaking bill visibility in Recibos for those chapters. The agreements model (rpu-agreements-model.md inv. 4) requires per-chapter links to be preserved for history rows.
- Areas
- scripts/repair-dup-contracts.ts+605−0scripts/lib/repair-dup-contracts-core.ts+132−0scripts/__tests__/repair-dup-contracts-core.test.ts+203−0
- Blast
- 3 files, +940/-0 lines (all new — no pre-existing code modified). Script is one-shot, retired after successful prod run. No domain code touched.
Findings · 9
correctness3
ON CONFLICT DO NOTHING in COPY path gives no observability when skipping
scripts/repair-dup-contracts.ts:352
When the holder already has a SUC for the same site, the INSERT silently skips — no log, no count. If the assumption is wrong (holder never had the link), the silence masks a missing link. A RETURNING clause to count inserted rows and log the result would give the operator confidence the copy actually executed.
INSERT omits version/created_at/updated_at — all have DB defaults (safe)
scripts/repair-dup-contracts.ts:348
The INSERT specifies only (public_id, site_id, utility_contract_id, created_by). Conventions lens confirmed all omitted columns have DB-level defaults (version DEFAULT 1, created_at/updated_at DEFAULT now(), id DEFAULT random UUID). The omission is correct.
Terminated chapter SUC links visibility depends on Contratos UI filter
scripts/repair-dup-contracts.ts:337
The comment asserts 'the Contratos list hides terminated rows (fix/rpu-links), so kept-link history chapters do not resurface as ghost rows.' This is correct IF the Contratos query filters on status != terminated. The script's correctness relies on that UI filter being in place — it is a cross-component dependency, not a bug here.
security2
INSERT…SELECT copies SUC rows without org-boundary assertion
scripts/repair-dup-contracts.ts:342
The INSERT…SELECT copies site links from deposed to holder matching only on utility_contract_id. No WHERE clause asserts that both contracts share the same org (via sites.org_id). The census query keeps pairs within the same contract_number so cross-org leakage is not expected in practice, but the same latent risk existed in the old UPDATE. Adding an org-equality guard in the INSERT's FROM clause or asserting it in plan construction would make the invariant explicit rather than implicit.
No SQL injection risk — all parameters properly bound via Drizzle sql template
scripts/repair-dup-contracts.ts:342
holder.contract_id and deposed.contract_id are interpolated via Drizzle's sql tagged template (parameterized placeholders, not string concatenation). No raw string interpolation in the new code.
tests2
FLIP_KEEP_HISTORY shell path has no automated test coverage
scripts/repair-dup-contracts.ts:334
The new `deposedSurvivesAsHistory` branch (INSERT INTO site_utility_contracts ON CONFLICT DO NOTHING) is exercised only by the staging rehearsal, not by any automated test. Classifier tests in repair-dup-contracts-core.test.ts cover classify() only. If ON CONFLICT silently swallowed every row (e.g. holder already had the links), the rehearsal would still report success. A post-repair count assertion (SELECT count(*) FROM site_utility_contracts WHERE utility_contract_id = holder_id) logged to stdout would make the rehearsal self-evidencing.
Staging rehearsal success is not machine-verifiable
scripts/repair-dup-contracts.ts:334
The 3/3 rehearsal confirms no exceptions were thrown, but there is no recorded pre/post count assertion proving SUC rows were actually created on the holder. A successful run that silently skips all INSERTs via ON CONFLICT would be indistinguishable from a successful copy.
improvement2
COPY branch INSERT missing comment on conflict-skip semantics
scripts/repair-dup-contracts.ts:340
The MOVE branch has a clear comment ('ON CONFLICT-safe: if the holder already has a SUC on the same site'). The COPY branch's ON CONFLICT DO NOTHING has no analogous comment explaining when the conflict fires and why silently skipping is correct. Adding '-- skip if holder already linked to this site (idempotent re-run)' mirrors the MOVE branch's style.
Dry-run display ternary could be hoisted to a named variable
scripts/repair-dup-contracts.ts:503
The inline ternary inside the template literal is long. Hoisting `const action = deposedSurvivesAsHistory ? 'terminate; ...' : 'soft-delete; ...'` and referencing `action` in the string would improve readability and reuse the `deposedSurvivesAsHistory` variable rather than re-evaluating `plan.verdict`.
History · 8 commits
- 80e0f62safeincremental0H · 0M · 1L2026-07-16 19:20
- d513853needs attentionincremental0H · 2M · 3L2026-07-16 19:13current
- 3de8c23safeincremental0H · 1M · 3L2026-07-14 14:23
- 8af2fabsafeincremental0H · 0M · 2L2026-07-14 14:16
- 8e6343eneeds attentionincremental0H · 1M · 3L2026-07-14 04:45
- 57eb1ebneeds attentionincremental0H · 2M · 4L2026-07-14 02:41
- a29f8f4needs attentionincremental0H · 4M · 6L2026-07-14 02:33
- 27179efneeds attentionfull5H · 4M · 3L2026-07-14 02:15