← all branches

feat/energia-led

needs attention
9a7f913 · incrementalPR #300reviewed 2026-07-14 17:41 UTC3H · 7M · 9L · 5I
The branch
Purpose
The energía module surfaces automated findings ('Hallazgos') over a customer's utility data — CFE billing errors and client-side optimization levers — as a durable, never-regressing ledger. This is the artifact that answers 'what ROI is Batu delivering' — the money number a CFO can put next to the invoice.
Goal
Utility-agnostic, contract-anchored findings ledger: refactor the finding domain from org-anchored (org_id + RPU-keyed dedupe) to contract-anchored (utility_contract_id + contract:type:period dedupe) with provider-neutral field names.
Sub-goals
  • PR1: Per-org toggleable energía module scaffold, greyed pre-launch (apps/platform/.../energia page)
  • PR2 (this branch): Schema + FCIS entity (type, decisions, queries, shells, mapper, errors, type-check) for utility_contract_findings
  • PR2 refactor (this commit): Remove org_id anchor, rename fields to utility-agnostic vocabulary, change dedupe key to contract:type:period
  • Two detectors as pure functions: detectEstimatedReadings, detectFpRecompute
  • RLS mirrors bills: service_role bypass + authenticated SELECT via SUC join chain
The changes (whole branch)
What
Renamed findings table to utility_contract_findings, removed org_id column, changed dedupe key from sha256(orgId:findingType:rpu:yearMonth) to sha256(utilityContractId:type:period??''), added billId (nullable FK with set null on delete) and currency column, renamed findingType→type, yearMonth→period, estimatedMxnAnnual→estimatedImpact, verifiedMxnAnnual→verifiedAmount, changed categories cfe_error→billing_error and operational→optimization, updated listByOrg to scope via SUC join instead of orgId column, added findByContract query, updated all tests.
Why
The original org-anchored design stored redundant org_id (derivable from the contract chain) and used CFE-specific vocabulary. The contract-anchored design works for any future utility/provider, eliminates the redundant org_id, and aligns the access model with bills and utility_contracts (shared-resource model).
Areas
domains/utility/src/finding/+21505packages/database/src/schema/+1300packages/database/drizzle/+480apps/platform/src/app/.../energia + sidebar + i18n+601domains/utility/src/events/+600docs/specs/+1220
Blast
28 files, +16,413/-5 (13,299 lines drizzle snapshot JSON noise; ~3,100 real code lines). Domain-only PR — no handler, no public API, no UI beyond the greyed scaffold. All changes behind the per-org soft-launch gate.
domain-only additive-migration no-backfill-needed rls-mirrored-from-bills
typecheck· 65/65 unit tests, typecheck, eslint, migration-consistency all pass per PR descriptionci/e2e· CI checks API not accessible for this runcoderabbit· No .coderabbit.yaml in repo

Findings · 24

correctness4

medium

Hardcoded '2026-04' straddle guard permanently suppresses April 2026 FP findings

domains/utility/src/finding/finding.decisions.ts

detectFpRecompute contains `if (row.yearMonth === '2026-04') continue;` written for the CFE §5.5 umbral transition month when either 95% or 97% might apply. Today is 2026-07-14 — April bills are 3 months old and the correct umbral is now deterministic. Any customer overcharged in April 2026 will never receive an fp_recompute_mismatch finding. Remove the guard now, or replace it with `if (yearMonth < '2026-05') continue;` and document the expiry. The test that locks in the suppression should also be removed.

low

fetchBillFactRowsForOrg DISTINCT ON axis (contract_number, year_month) wrong for contract-anchored ledger

domains/utility/src/finding/finding.queries.ts

DISTINCT ON (uc.contract_number, b.year_month) collapses to one bill when multiple utility_contracts share the same contract_number and overlap on year_month (compound unique is contract_number+service_id+tariff_id). In a terminate-and-replace scenario where both old and new contracts have a bill for the same month, the finding could be anchored to whichever contract's bill wins the DISTINCT, silently dropping the other. The correct dedup axis for a contract-anchored ledger is (uc.id, b.year_month) not (uc.contract_number, b.year_month).

low

jsonb_object_agg in lateral join throws on duplicate concept_name within one bill

domains/utility/src/finding/finding.queries.ts

jsonb_object_agg(bcc.concept_name, ...) inside the LATERAL join has no duplicate-key guard. If a bill has two line_items whose conceptId values resolve to the same concept_name, PostgreSQL raises a runtime error rather than silently dropping one. The concept catalog enforces unique concept_name for normally-ingested bills, but a malformed or manually-inserted bill would silently break org-wide finding detection.

info

FindingDetectedEvent field renames safe — zero consumers today

domains/utility/src/events/finding.events.ts

findingType→type, yearMonth→period, estimatedMxnAnnual→estimatedImpact on the event payload. No handler, subscriber, or Lambda consumer references this event type yet. The rename is safe; when an EventBridge consumer is wired, the in-flight event shape must match.

security3

low

findByContract unscoped and exported — callers must resolve org access externally

domains/utility/src/finding/finding.queries.ts

findByContract returns all findings for a contract with no org guard. No handler exists in PR2 (domain-only), so there is no live exposure. But the function is exported in FindingFCIS and is available for any PR3+ handler to call without noticing the missing guard. Consider removing it from the public barrel, adding a findByContractForOrg overload, or renaming to make the unscoped nature explicit at the call site.

info

RLS SELECT policy structurally equivalent to bills — no regression

packages/database/src/schema/utility-contract-findings.ts

ucf_select_member mirrors bills_select_member exactly: same join path, same get_user_org_ids() SECURITY DEFINER helper. No cross-org leakage path exists.

info

No SQL injection risk in fetchBillFactRowsForOrg raw SQL

domains/utility/src/finding/finding.queries.ts

orgId is interpolated via Drizzle's sql template literal as a parameterized bind value with a ::uuid cast. No injection surface.

conventions4

medium

updateEstimate also lacks version WHERE predicate and deviates from updateWithVersion naming

domains/utility/src/finding/finding.queries.ts

Same root issue as updateStatus — the currentVersion argument is passed but not used in the WHERE clause. Canonical convention for versioned updates across the codebase is updateWithVersion; descriptive names are fine but only if the optimistic-lock contract is actually enforced. Also: DetectFindingsResult.updated is incremented before the null-check on the returned row, so on a hard-delete race the counter inflates by 1 with no corresponding outbox event.

medium

FINDING_CATEGORIES / FINDING_STATUSES / FINDING_SEVERITIES duplicated across schema and domain type

domains/utility/src/finding/finding.type.ts

These constants are independently defined in both packages/database/src/schema/utility-contract-findings.ts and domains/utility/src/finding/finding.type.ts. Currently they agree. The type-check AssertEqual will catch a drift if the Drizzle { enum } hint diverges from FindingCategory, but a future edit to one file won't cause a compiler error in the other. Canonical pattern: define once in the domain type and import in the schema (or re-export from the schema). FINDING_STATUSES and FINDING_SEVERITIES have the same duplication.

info

UpsertFindingPatch correctly omits immutable anchor fields

domains/utility/src/finding/finding.decisions.ts

utilityContractId, type, and period are part of the dedupe key, so a key match guarantees they already match the existing row. They are correctly excluded from the patch.

info

decideUpsertFinding correctly checks billId (not utilityContractId) for change detection

domains/utility/src/finding/finding.decisions.ts

billId can legitimately differ on re-detection when CFE's delete-and-replace supersedes the original bill (onDelete: set null). Checking utilityContractId would be dead code — same dedupeKey guarantees same contract. The change is correct.

tests7

high

findByContract is untested — new query function has zero coverage

domains/utility/src/finding/__tests__/finding.integration.test.ts

findByContract was added as a first-class exported query in this refactor but has no test at all. Given that it is the contract-anchor read path (the named invariant of this PR), a missing test is a critical gap. Suggested: insert a finding on contractA and one on contractB, call findByContract(db, contractA.contractId), assert only contractA's finding is returned.

high

Outbox events never asserted in integration tests — delivery guarantee is structurally untested

domains/utility/src/finding/__tests__/finding.integration.test.ts

detectFindingsForOrgShell emits utility.finding.detected for every insert/update in the same transaction (the guaranteed-delivery invariant). The integration test only checks DetectFindingsResult counters (inserted, updated, unchanged) but never queries the outbox table to assert an event was written. The entity + outbox in same tx invariant is completely untested here.

medium

12-bill rolling window per-RPU cutoff is completely untested at the DB layer

domains/utility/src/finding/__tests__/finding.integration.test.ts

fetchBillFactRowsForOrg enforces DENSE_RANK() <= 12 per RPU. No test inserts 13+ bills for an RPU and asserts the oldest is excluded. The decisions tests operate on caller-provided slices so windowing is purely a query concern — exercised only indirectly through the idempotent-double-run test which inserts only one bill.

medium

Cross-org isolation assertion is fragile and potentially vacuous

domains/utility/src/finding/__tests__/finding.integration.test.ts

items.items.every((f) => f.utilityContractId === orgA.contractId) works only because orgA has exactly one contract in the test setup. With multiple contracts per org (a valid real-world case), findings on orgA's second contract would be incorrectly rejected. The correct assertion is the negative: .every((f) => f.utilityContractId !== orgB.contractId). Also: Array.every() returns true for an empty array — missing a prerequisite expect(items.items.length).toBeGreaterThan(0) assertion.

medium

decideUpsertFinding noop: numeric-parity case (string DB value vs number command) untested

domains/utility/src/finding/__tests__/finding.decisions.test.ts

The noop test covers the null-null case only. The case where existing.estimatedImpact is a numeric string ('100.00') and command.estimatedImpact is the same number (100) is not tested: existingEstimated = Number('100.00') = 100, command.estimatedImpact = 100 → equal → should noop. A test for this string-to-number equality noop is missing.

low

detectFpRecompute critical severity threshold boundary ($5000, 20%) untested

domains/utility/src/finding/__tests__/finding.decisions.test.ts

Critical requires BOTH |delta| > 5000 AND relativeDeviation > 0.2. Tests cover clearly-above and clearly-below but not the exact boundary (which uses strict > so |delta| = 5000 should be warning). Missing boundary tests mirror the detectEstimatedReadings suite's pattern (line 545: 'deviation exactly at 20% does NOT escalate to critical').

low

Integration test missing: status transition at DB layer and optimistic-lock race

domains/utility/src/finding/__tests__/finding.integration.test.ts

The spec lists 'status transitions' as an integration invariant. Only a single happy-path verified transition exists (inside the never-regress-status test). No test covers: (a) updateStatus returning null when version is stale, or (b) the full detected→in_action→verified chain at the DB layer.

improvement5

medium

rpu in FindingDetectedEvent is sourced from in-memory candidate — not a stable property of the finding

domains/utility/src/finding/finding.shells.ts

Both INSERT and UPDATE outbox events use `rpu: candidate.rpu` (the contract_number from BillFactRow). The finding entity no longer stores rpu, so this field is a denormalized convenience sourced at detection time. Any downstream consumer indexing by the event's rpu may see inconsistencies on re-detection events if the contract-to-RPU mapping is not 1:1. The event type doc should clarify that rpu is detection-time-only and not a stable property of the persisted finding.

low

Migration 0058 CREATE POLICY statements lack idempotency guard

packages/database/drizzle/0058_steady_human_cannonball.sql

The two CREATE POLICY statements have no IF NOT EXISTS guard. Per packages/database/CLAUDE.md Supabase Branching Caveat, migrations arriving post-fork are marked applied but never run, so manually re-applying via psql will fail on these lines. The FK blocks in the same migration already use the DO $$ BEGIN...EXCEPTION WHEN duplicate_object THEN null; END $$; pattern — wrap the policy statements the same way.

low

currency: 'MXN' hardcoded in both detectors — breaks provider-neutral claim

domains/utility/src/finding/finding.decisions.ts

detectEstimatedReadings and detectFpRecompute both hardcode currency: 'MXN'. The Finding type header describes currency as ISO-4217; provider-neutral. FindingCandidate exposes currency as a field specifically to support this, but both current detectors bypass it. A named constant CFE_CURRENCY = 'MXN' at module level (or a detector-context object) would make the assumption explicit.

low

resolveDetectorVersion fallback silently mints version strings for misspelled types

domains/utility/src/finding/finding.shells.ts

resolveDetectorVersion(type) falls back to `${type}@1` for any type not in DETECTOR_VERSIONS. A misspelled type silently produces a wrong version string instead of failing fast. A dev-time assertion or structured warning log on fallthrough would surface the omission at development time.

info

inArray with Drizzle subquery builder confirmed safe — established codebase pattern

domains/utility/src/finding/finding.queries.ts

Drizzle-orm 0.37.0 supports passing a subquery builder directly to inArray, generating SQL IN (SELECT ...). The same pattern is used in bill.queries.ts and utility-contract.queries.ts. No issue.

correctness/conventions1

high

updateStatus WHERE clause missing version predicate — optimistic lock is decision-layer-only

domains/utility/src/finding/finding.queries.ts

updateStatus (and updateEstimate) filter by eq(findings.id, id) with no version gate. The doc comment claims 'Returns null if no row was updated (e.g. concurrent modification)' but that is impossible: a valid id will always match. A concurrent status transition (detected→verified races with detected→dismissed) silently wins — the last writer wins without detection. Add and(eq(findings.id, id), eq(findings.version, currentVersion)) to the WHERE clause so a racing write causes 0 rows affected → null return. The decision layer's version check in decideTransitionFindingStatus is necessary but not sufficient — it only catches conflicts visible before the write.

History · 2 commits

  1. 9a7f913needs attentionincremental3H · 7M · 9L2026-07-14 17:41current
  2. 8dfc4a2blockedfull3H · 7M · 8L2026-07-11 05:50