← all branches

fix/rpu-unique

needs attentionviewing older commit
21c112e · fullPR #321reviewed 2026-07-16 23:10 UTC3H · 5M · 6L · 4I
The branch
Purpose
Enforce the agreements-model invariant (§5 of docs/development/rpu-agreements-model.md) at the DB level: an RPU (service point) has AT MOST ONE active agreement chapter at a time.
Goal
Add a partial unique index as a DB backstop so a concurrent double-activation fails with 23505 instead of silently forking the RPU's present.
Sub-goals
  • SG-1: Add partial unique index uq_utility_contracts_one_active_per_rpu on utility_contracts(contract_number) WHERE status='active' AND deleted_at IS NULL
  • SG-2: Fix ReactivateContract write order to demote-first/reactivate-second — required because the index is NOT deferrable (two active rows may not coexist even transiently within a transaction)
The changes (whole branch)
What
New DB partial unique index (migration 0061) + Drizzle schema declaration; reordered 33 lines in the ReactivateContract shell path to demote (terminate old active) before reactivating the target row; extended test timeouts to account for cross-region staging latency.
Why
Decision layer (#314's evidence-driven routing) already guarantees the invariant; the index turns a concurrent double-activation from a silent RPU fork into a hard 23505. Gated on verified clean censuses (prod: 339 dup groups all NO_ACTION_OK, 0 foreign bills; staging: 13 dup groups all NO_ACTION_OK, 0 foreign bills).
Areas
domains/utility/src/utility-contract+3530packages/database/drizzle (migration + snapshot + journal)+131440packages/database/src/schema+90
Blast
6 files, +13188/−30 (13136 from generated drizzle snapshot). Functional changes: 1 new non-deferrable partial unique index (DB schema change) + 33-line reorder in one shell code path. No API surface change, no auth change, no new external calls.
Known deferred edge: manual double-activation via updateContractShell → 23505 → 500 instead of 409; scoped to feat/rpu-history
CI checks· GitHub Actions check status not accessible via this tokencfe-job-intent integration tests· 28/28 green per PR description (against staging, includes reactivation-swap and T&R full-shell tests)ensure-contract-evidence integration tests· 16/16 green per PR description — covers swap, tie, no-demote, OCC-rollback paths under new write orderCodeRabbit· No .coderabbit.yaml in repo

Findings · 18

correctness5

high

ensureContractForTariff CreateContractForTariff arm now crashes with 23505

domains/utility/src/utility-contract/utility-contract.shells.ts:453

The CreateContractForTariff branch inserts a new contract with status='active' for the same contract_number (RPU) as the existing source contract, which remains active. The new partial unique index fires a 23505 on that insert because both rows share the same contract_number with status='active' and deleted_at IS NULL. The shell does NOT terminate the existing contract before inserting. Currently dead code (not called from outside the domain), so no prod impact today — but any future caller reaches an unhandled crash. Fix: terminate the existing contract before inserting, mirroring the TerminateAndReplace branch.

medium

insertContractRaceSafe misidentifies new-index violation as compound-key collision

domains/utility/src/utility-contract/utility-contract.shells.ts:79

When a 23505 fires on contractQueries.insert, insertContractRaceSafe assumes it came from idx_utility_contracts_compound_key and calls findByCompoundKey to recover. If the violation instead came from uq_utility_contracts_one_active_per_rpu (e.g., two concurrent CreateContractFromBills calls for the same RPU with different tariff codes — different compound keys, same contract_number), findByCompoundKey returns null and the shell receives { contract: null, adopted: true }, triggering ContractErrors.databaseError('insert') — a 500. In practice OCC serialises these paths so it's unlikely to fire here, but when it does the error is opaque and misleading.

medium

demotedContract in return value carries stale version and status

domains/utility/src/utility-contract/utility-contract.shells.ts:987

In the ReactivateContract branch, `demoted` is assigned from d.demoteContract (the pre-update snapshot). After contractQueries.updateWithVersion succeeds, the DB row has version=demoted.version+1 and status='terminated', but EnsureContractFromBillsResult.demotedContract still carries { status: 'active', version: demoted.version }. No current caller reads demotedContract.version/status to drive a subsequent write, so this is latent. Fix: capture the updateWithVersion return value and expose the updated row.

low

Concurrent wizard double-submit now surfaces as unhandled 23505 → 500

domains/cross-domain/src/contract-wizard.shells.ts

Pre-PR, wizard contracts with null tariffId/serviceId were exempt from the compound unique index (NULLs not equal in Postgres unique indexes). Post-PR, the new partial index on contract_number fires on concurrent wizard double-submits that both pass the PATH-1/PATH-2 read checks before either tx commits. The 23505 bubbles as a 500; rollback is correct so no data corruption. Adding SAVEPOINT-based recovery similar to insertContractRaceSafe would convert this to a clean conflict response.

info

Demote-first write order correctly handles non-deferrable constraint

domains/utility/src/utility-contract/utility-contract.shells.ts

PostgreSQL checks non-deferrable constraints per-statement. Terminating the old active row first, then activating the target ensures the constraint holds after each individual statement. Correct implementation.

security3

medium

INSERT RLS withCheck:true enables DoS via intentional index collision

packages/database/src/schema/utility-contracts.ts:124

The INSERT policy for authenticated users has withCheck: sql`true` — no org-scoping on writes. Any authenticated user who knows a valid CFE contract number can INSERT a row with status='active', causing a 23505 that blocks ensureContractFromBillsShell from activating the legitimate contract for that RPU. The pipeline runs as service_role (bypasses RLS), but the unique constraint is enforced regardless. This turns an incomplete rogue INSERT into a persistent blocking condition for bill collection. The permissive INSERT policy pre-existed this PR; the new unique index gives it concrete impact.

low

UPDATE withCheck is unconditionally true — pre-existing RLS gap amplified

packages/database/src/schema/utility-contracts.ts:125

The UPDATE policy's withCheck: sql`true` imposes no constraint on values written. An authenticated user with read access to a contract (via SUC → site in their org) can UPDATE that row's status to 'active' directly via PostgREST. Combined with the unique index, a successful status escalation can pin the RPU in a blocked state for the pipeline. Pre-existing gap, but the partial unique index now gives it concrete, observable impact.

info

Partial index predicate uses string literal — consistent with schema enum

packages/database/drizzle/0061_dusty_ben_parker.sql

WHERE clause uses string comparison against a text column (status uses text enum, no DB-level type). Safe and consistent with other partial indexes in the table. No injection risk — this is DDL, not dynamic SQL.

conventions2

low

ensureContractForTariff missing 'Shell' suffix (pre-existing violation)

domains/utility/src/utility-contract/utility-contract.shells.ts:390

Canonical-form naming requires shell functions to be named {operation}Shell. `ensureContractForTariff` follows the full FCIS shell pattern but is missing the suffix. Pre-existing violation not introduced here; the companion ensureContractFromBillsShell is correctly named.

low

Migration SQL and _journal.json missing trailing newline

packages/database/drizzle/0061_dusty_ben_parker.sql:1

0061_dusty_ben_parker.sql ends without a newline (POSIX text-file requirement). _journal.json also has no trailing newline. Generated files, but inconsistent with other migrations in the repo and can cause issues with psql \i or diff tooling.

tests4

high

No test directly exercises the new DB constraint

domains/utility/src/utility-contract/__tests__/ensure-contract-evidence.integration.test.ts

The partial unique index uq_utility_contracts_one_active_per_rpu is proven to exist by the migration but its enforcement is never tested. No integration test attempts to create a second active row for the same contract_number and asserts a 23505. A future migration error or typo in the index predicate would be invisible. Minimal gap-filler: insert contract A (active), attempt to insert contract B with same contract_number and status='active', expect DB throw with code 23505.

high

No test for 'demote succeeds, reactivate fails' OCC rollback path

domains/utility/src/utility-contract/__tests__/ensure-contract-evidence.integration.test.ts

The PR swapped write order to demote-first, reactivate-second. Existing OCC test only covers demotion updateWithVersion failing (stale version) → tx rolls back. There is no test for: demote succeeds but reactivate updateWithVersion returns null (concurrent writer modified the terminated row between FETCH and reactivation). In that scenario the whole tx rolls back — including the demote — leaving the RPU with its original single active row. This rollback is the sole safety net against a zero-active-contract state after the write-order change, and it is not covered.

medium

23505 from UPDATE (reactivate step) untrapped — surfaces as 500, not 409

domains/utility/src/utility-contract/utility-contract.shells.ts

insertContractRaceSafe catches 23505 for INSERTs but the reactivation updateWithVersion is an UPDATE with no analogous 23505 guard. If a concurrent tx activates another row for the same RPU between the demote and reactivate writes, the index fires on the UPDATE and the unhandled exception surfaces as a 500. The PR acknowledges the manual updateContractShell path's 23505→500 gap but does not track the equivalent hazard in the ReactivateContract shell. This sub-path lacks both a guard and a test.

low

OCC rollback test comment describes old write order — misleading after swap

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts

An inline comment says 'the reactivation that ran BEFORE the failed demotion was rolled back'. After the write-order swap (demote-first, reactivate-second), the reactivation has not yet run when the demotion fails — there is nothing to roll back on that side. Test behaviour is correct but the comment will mislead future maintainers about the failure sequence.

improvement3

medium

`let demoted` alias is unnecessary indirection — simplify to d.demoteContract

domains/utility/src/utility-contract/utility-contract.shells.ts:898

`demoted` is always equal to `d.demoteContract` (set on line 900, never mutated). The `if (demoted)` guard at the attachment-transfer block is semantically identical to `if (d.demoteContract)`. A reader must trace the let/assign/re-check to confirm they're the same. Simpler: drop the let, use d.demoteContract directly in the first block, and use `if (d.demoteContract)` for the attachment-transfer guard. The alias was needed in the old code because `reactivated.publicId` was referenced (a bug — reactivated not yet in scope); the PR fixed that bug by using d.contract.publicId but left the aliasing pattern in place.

low

Mixed .where() styles on adjacent uniqueIndex declarations

packages/database/src/schema/utility-contracts.ts:97

The preceding compoundKeyIdx uses Drizzle-native isNull(table.deletedAt) for its partial WHERE. The new oneActivePerRpuIdx uses a raw sql`` template for a compound condition. Both produce identical SQL. The compound condition requires raw sql (Drizzle partial-index API only accepts one expression), so a short inline comment would pre-empt the 'why not isNull()?' question.

info

suspended status not covered by partial index — invariant is application-enforced for that state

packages/database/src/schema/utility-contracts.ts

The index predicate covers only status='active'. A suspended row doesn't block a second active row from being created. In the current flow the decision layer always routes suspended rows through TerminateAndReplace first, so no live bug. Worth noting that the DB only backstops the active state, not suspended.

seo1

info

SEO lens skipped — no apps/web changes

n/a

Diff contains no marketing website files. SEO lens is a no-op for this PR.

History · 4 commits

  1. c145f78needs attentionfull0H · 2M · 5L2026-07-17 19:03
  2. 6352bc8needs attentionincremental0H · 3M · 5L2026-07-17 18:44
  3. cc8af77needs attentionincremental0H · 1M · 3L2026-07-17 00:34
  4. 21c112eneeds attentionfull3H · 5M · 6L2026-07-16 23:10current