← all branches

fix/dup-repair

needs attentionviewing older commit
d513853 · incrementalPR #306reviewed 2026-07-16 19:13 UTC0H · 2M · 3L · 4I
The branch
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
The changes (whole branch)
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+6050scripts/lib/repair-dup-contracts-core.ts+1320scripts/__tests__/repair-dup-contracts-core.test.ts+2030
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.
one-shot: run on prod only after PR #298 is live staging-rehearsal-complete: 3/3 groups repaired (2026-07-13)
ci· No CI check results available for PR 306coderabbit· No .coderabbit.yaml presentstaging-rehearsal· 3/3 groups repaired, verify 0 actionable (2026-07-13)

Findings · 9

correctness3

low

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.

info

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.

info

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

medium

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.

info

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

medium

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.

low

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

low

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.

info

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

  1. 80e0f62safeincremental0H · 0M · 1L2026-07-16 19:20
  2. d513853needs attentionincremental0H · 2M · 3L2026-07-16 19:13current
  3. 3de8c23safeincremental0H · 1M · 3L2026-07-14 14:23
  4. 8af2fabsafeincremental0H · 0M · 2L2026-07-14 14:16
  5. 8e6343eneeds attentionincremental0H · 1M · 3L2026-07-14 04:45
  6. 57eb1ebneeds attentionincremental0H · 2M · 4L2026-07-14 02:41
  7. a29f8f4needs attentionincremental0H · 4M · 6L2026-07-14 02:33
  8. 27179efneeds attentionfull5H · 4M · 3L2026-07-14 02:15