← all branches

feat/energia-det

needs attentionviewing older commit
af5476d · fullPR #308reviewed 2026-07-16 13:21 UTC5H · 10M · 8L · 3I
The branch
Purpose
Energía module — P1 detection engine. Corpus-verified pure-function detectors that flag CFE billing errors and operational optimisation opportunities, writing results into the utility_contract_findings ledger introduced in PR2.
Goal
Ship 10 production-grade detectors (6 billing_error + 4 optimization) as pure functions wired into a detection shell, with regression fixtures, a manual CLI runner, and the soft-launch gate + sidebar stub for the energia module.
Sub-goals
  • SG-1: Finding FCIS entity — type, type-check, decisions, errors, mapper, queries, shells, barrel export
  • SG-2: 10 corpus-verified detectors (estimated_reading, fp_recompute_mismatch, fp_regime_change, inactive_service_charges, vat_recompute, dap_present, consumption_yoy_spike, fp_chronic_penalty, demand_overage_risk, demand_oversized)
  • SG-3: Detection shell (detectFindingsForOrgShell) with outbox events, idempotent upsert, per-org scoping
  • SG-4: DB schema + migration (utility_contract_findings, UNIQUE on dedupeKey, version column)
  • SG-5: Energy module soft-launch gate + sidebar entry + greyed Pronto page
  • SG-6: CLI runner script (scripts/energia/detect-findings.ts) for manual batch detection
The changes (whole branch)
What
Added the complete finding FCIS entity (1097-line decisions file with 10 detectors + upsert/transition decisions, errors, mapper, queries, shells), the utility_contract_findings DB table + migration, FindingDetectedEvent + schema, soft-launch registration of the energia module, sidebar stub, greyed placeholder page, and a CLI runner.
Why
PR1 in the energía module rollout. The detection engine is the core value-delivery mechanism — corpus-verified thresholds translate raw CFE bill data into actionable findings for solar integrators and enterprise energy managers.
Areas
domains/utility/src/finding+33090domains/utility/src/events+611domains/utility/src+231packages/database/src/schema+1330packages/database/drizzle+133470apps/platform/src+723scripts/energia+1730docs/specs+1220packages/api/src/schemas+11
Blast
30 files, +17,241 net lines across utility domain, DB schema/migration, platform app, and scripts. Migration adds utility_contract_findings table; no existing table modifications. Soft-launch addition of energia module to ORG_MODULES + OrgModuleKeySchema (covariance-safe, same-commit widening).
new-db-table outbox-events soft-launch-module-addition cli-runner-no-auth
ci· No check runs registered for this SHA yetcoderabbit· No .coderabbit.yaml in repo

Findings · 24

correctness4

high

`updated` counter incremented before null-check on updateEstimate result

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

In the update branch, `updated += 1` runs unconditionally before the `if (finding)` guard that gates the outbox event. If `updateEstimate` returns null (concurrent delete between batch-fetch and write), the shell returns updated:1 even though zero DB rows were modified. Fix: move `updated += 1` inside the `if (finding)` block.

medium

createBillFactRow test factory missing 5 required BillFactRow fields

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

The factory omits `measuredDemandKw`, `totalNet`, `totalGross`, `tax`, `dapMxn` — all required non-optional fields on BillFactRow. TypeScript misses this because test files are excluded from tsconfig. Any future test using these rows for demand/inactive/VAT/DAP detectors will silently get undefined instead of null. The correct factory is `row()` in finding.detectors.test.ts (line 30–53).

medium

detectInactiveServiceCharges skips dead meters on demand-only tariffs

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

The pre-filter `r.kwhTotal !== null` excludes demand-only tariff meters (GDMTH/GDMTO/DIST/DIT) where kwhTotal is always null. A de-energised substation on GDMTH with zero measured demand + fixed charge is exactly the inactive-service pattern but is never flagged. Relax the filter or handle null kwhTotal explicitly.

low

updateEstimate / updateStatus missing version = currentVersion in WHERE clause

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

Both functions write version: currentVersion+1 in SET but don't add AND version = currentVersion to WHERE. A concurrent actor that already bumped the version will be silently overwritten. The FindingVersionConflictError type and constructor exist; anchor the WHERE clause: .where(and(eq(findings.id, id), eq(findings.version, currentVersion))) to actually use them.

security4

high

sql.raw with unparameterized UUID array interpolation in runner

scripts/energia/detect-findings.ts:131

The summary query builds `ARRAY['${id}',...']::uuid[]` via string concatenation inside `sql.raw(...)`, bypassing Drizzle's parameterisation. UUIDs come from a prior DB SELECT (low injection risk today), but the pattern is structurally unsafe and should not exist as a model. Use `inArray` or `sql` tagged template with bind params, mirroring `finding.queries.ts` `listByOrg`.

medium

findByContract / findByDedupeKey are org-unscoped — cross-org leakage risk when used from handlers

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

Both functions return findings without any org constraint. No handler ships in this PR, but the queries are exported in FindingFCIS. A future handler calling findByContract with a user-supplied utilityContractId without prior org-membership verification would expose findings from any org. Either add an orgId parameter or mark these service-role-only via doc comment.

medium

detectFindingsForOrgShell has no auth guard — any caller with a DB handle can scan any org

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

The shell accepts an arbitrary orgId without verifying the actor is authorised to scan it. It uses raw service-role db (not createRLSDb), so RLS never fires. The actor context is used for logging/outbox attribution only. If a handler wires this shell without checking actor.orgId === orgId, any authenticated user could trigger detection for any org. Add org-membership enforcement or document clearly that callers MUST enforce it before this shell gets a handler in PR3.

low

DATABASE_URL partially exposed in runner stdout

scripts/energia/detect-findings.ts:68

The script logs the DB URL with password redacted but host/username/dbname visible. On a preview stack the host encodes the Supabase project ref. Log only an environment label or just the host portion.

conventions4

medium

Detector functions named detect* instead of decide* in finding.decisions.ts

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

Canonical naming: pure decision functions are decide{Operation}. The 10 detector functions use detect* (not decide*) and stretch the file to 1097 lines. The file comment acknowledges this as a deliberate port pattern. Not a hard FCIS violation (functions are pure), but deviates from the naming convention. A dedicated finding.detectors.ts would follow canonical file-per-role structure.

low

transitionFindingStatusShell missing — decision has no shell wrapper

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

decideTransitionFindingStatus is implemented but no shell function wraps the fetch→decide→write choreography. A future PR3 handler may bypass the shell pattern. Track as PR3 prerequisite.

info

createHash import: acknowledged synchronous-computation exception to no-I/O rule

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

File-level comment explicitly acknowledges the createHash import and justifies it as a deterministic synchronous computation matching the precedent in lib/bill-unique-index.ts. No action needed.

info

Barrel exports finding types directly alongside FindingFCIS namespace — consistent with domain pattern

domains/utility/src/index.ts:125

Consistent with how bill, utility-contract, and other entities are exported. No deviation from actual convention. No action needed.

tests8

high

Outbox event emission not verified in integration tests

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

The idempotency test verifies inserted/updated/unchanged counts but never queries the outbox_events table. Per FCIS rules, outbox events must land in the same transaction; if the outboxQueries.insert call ever silently fails, the finding persists but the downstream event never fires. Add: assert outbox row with event_type='utility.finding.detected' exists after first run; second run emits zero new outbox rows.

high

No integration test for the update path (re-detection changes estimate/severity)

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

The double-run test only covers insert (first run) and noop (second identical run). No test modifies bill data between runs to trigger an `update` decision and verify the row is patched in the DB plus a second outbox event emitted. The update branch in finding.shells.ts lines 211–239 is untested at the DB level.

high

fetchBillFactRowsForOrg SQL only exercised for estimated_reading concept in integration tests

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

The integration test seeds only `currentReadingType: E` and `kwh`. The CTE extracts 8+ concept names (powerFactor, powerFactorCharge, kw, totalBill, dap, hiredDemand…). A typo in any concept_name string would produce silent nulls that break 7 of the 10 detectors at runtime but pass all current tests.

medium

No empty-input test for any detector

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

None of the 10 detectors are tested with an empty [] input. The shell has an early-exit (shells.ts:127), but the detectors themselves iterate the array — empty-input behavior is untested across all 10.

medium

Threshold boundary tests missing for multiple detectors

domains/utility/src/finding/__tests__/finding.detectors.test.ts:153

consumptionYoySpike: no test for exactly 15.01% (should fire warning) or exactly 40% (confirm stays warning). detectDemandOversized: no test at exactly 85% utilisation. detectInactiveServiceCharges: no test at exactly 6 bills + $1,000 avg. These probe the strict vs. >= boundary in each condition.

medium

listByOrg status filter and pagination not integration-tested

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

findingQueries.listByOrg supports status filter + limit/offset pagination but no integration test exercises these parameters. A broken WHERE clause on status or off-by-one in offset would go undetected.

low

Concurrent deduplication race not handled or tested

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

The schema has a UNIQUE constraint on dedupeKey but the shell has no 23505 unique-constraint catch. Two concurrent detectFindingsForOrgShell calls for the same org could throw an uncaught DB error instead of returning ok(). Not tested.

info

Integration tests gated on POSTGRES_URL with no describe.skipIf guard

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

Follows monitoring-subscription.integration.test.ts precedent. CI without a DB URL throws rather than skips — less actionable error messages. Consistent with domain convention; low priority.

improvement4

medium

groupByRpuDesc called independently 10 times on the same input

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

Each of the 8+ detectors calls groupByRpuDesc(input) independently, performing the full O(n) grouping + per-RPU sort 10 times on identical data. The shell has the full rows array before dispatching — pass a pre-built Map<string,BillFactRow[]> into each detector instead. Matters for nightly Lambda runs over large orgs.

medium

Outbox event assembly duplicated between insert and update branches

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

The outboxQueries.insert call with identical eventType/aggregateType/eventData shape appears twice (insert path lines 190–208, update path lines 220–238). Extract an emitFindingDetected(tx, finding, orgId, candidate, actor) helper. Reduces divergence risk when eventData fields evolve.

low

Shell opens a DB transaction even when every decision is noop

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

After decideUpsertFinding runs for all candidates, the shell unconditionally opens a transaction. If every decision.action === 'noop' (common on re-runs), the transaction opens, iterates, and commits with zero writes. Add a hasWrites guard before the transaction.

low

Inconsistent window-slicing approach across detectors (4 patterns for the same operation)

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

Four distinct patterns for 'get each RPU's non-legado last-N rows': takeRecentPerRpu, groupByRpuDesc+filter+slice, inline groupByRpuDesc+filter, and groupByRpuDesc without legado filter. Extract a sliceNonLegadoWindow helper to make intentional legado-inclusive detectors visibly distinct.

History · 3 commits

  1. 67d4f5csafeincremental0H · 0M · 1L2026-07-21 19:41
  2. 40b0c87safeincremental0H · 2M · 3L2026-07-16 14:18
  3. af5476dneeds attentionfull5H · 10M · 8L2026-07-16 13:21current