← all branches

feat/energia-api

needs attention
51f5a76 · fullPR #328reviewed 2026-07-21 23:04 UTC5H · 9M · 10L · 4I
The branch
Purpose
Consolidation PR landing the complete energía findings stack — previously reviewed as 3 stacked PRs (#300 ledger, #308 detection engine, #310 read API) that got merged into their stacked branches instead of main when the stack was created. The scaffold (#299) is already in main.
Goal
Bring the same already-reviewed, verified content of #300+#308+#310 into main in one merge, with migration 0062 renumbered to avoid collision with 0058-0061 added by main after the stack diverged.
Sub-goals
  • SG-1 Ledger: utility_contract_findings table (migration 0062), optimistic locking, RLS mirroring bills
  • SG-2 Detection engine: 10 pure-function detectors verified against corpus study, upsert shell with never-regress-status guarantee
  • SG-3 Read API: GET /findings + GET /findings/:publicId, JSend, energia entitlement gate, org-isolation via contract-access chain
  • SG-4 UI: soft-launch gated sidebar entry + teaser Pronto page
The changes (whole branch)
What
38 files, +5,824 / -5 lines (excluding generated drizzle meta snapshot). New: Finding FCIS entity (type, decisions, errors, queries, shells, mapper, type-check, index), detection engine (10 detectors as pure functions), finding events, API contract+handler+mapper, finding.schemas, DB schema + migration, soft-launch gating, sidebar entry, teaser page, detect-findings.ts script.
Why
The stack's base PRs merged into each other instead of main (GitHub never retargeted stacked bases after the original tree was merged). This PR recovers the content with a renumbered migration that applies cleanly.
Areas
domains/utility/src/finding+40600apps/platform/src/api+4160packages/api/src/schemas+1491packages/database+410apps/platform (UI, soft-launch, messages)+552scripts/energia+1810
Blast
34 files changed, +5,824 / -5 across domain/API/DB/UI layers. Additive-only (new table, new routes, new nav item). Migration 0062 is a CREATE TABLE (applies clean to prod — table doesn't exist). Zero changes to existing feature paths.
Consolidation of already-reviewed PRs — primary author review already occurred on #300/#308/#310 PR note: e2e check may fail due to preview DB state (renumber footgun), not a code defect
ci· CI check rollup inaccessible via token scopecoderabbit· No .coderabbit.yaml in repo

Findings · 23

correctness3

high

Concurrent detection runs can throw unhandled 23505 unique-constraint violation

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

The db.transaction() block in detectFindingsForOrgShell has no try/catch and the insert query has no ON CONFLICT clause. If two concurrent runs process the same org simultaneously, both see no existing finding for a given dedupeKey (step 3, outside the transaction), both decide 'insert', and the second transaction throws a postgres 23505 unique-constraint violation on uq_ucf_dedupe_key. This exception propagates as an unhandled rejection — not wrapped in err() — breaking the declared Promise<Result<..., DetectFindingsError>> contract. Fix: add ON CONFLICT (dedupe_key) DO NOTHING to the insert (and skip the outbox event on conflict), or wrap the transaction body in try/catch and convert 23505 to a typed DetectFindingsError.

medium

detectInactiveServiceCharges over-estimates annual impact 2× for bimonthly RPUs

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

estimatedImpact = round2(12 * avgNet) assumes monthly billing. Bimonthly RPUs (accounts 01–40) produce 6 bills/year, not 12. For avgNet=$500/bill, the reported impact is $6,000 when actual is $3,000. The over-estimate is 2×. The corpus SQL source has the same limitation; at minimum document the assumption in a JSDoc comment.

low

verified→verified self-transition permitted despite verified being terminal

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

isValidFindingStatusTransition returns true when from===to. FINDING_STATUS_TRANSITIONS.verified is [] (terminal). This allows re-calling the transition endpoint with toStatus='verified' and a new verifiedAmount on an already-verified finding (a version bump still occurs). If verified is truly terminal in PR2, the from===to short-circuit should be conditional: skip the early-true for 'verified'.

security1

low

Unenforced findByPublicId exported alongside org-scoped variant — future misuse risk

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

findingQueries exports findByPublicId(db, publicId) with NO org scoping. A future handler that reaches for this simpler function (instead of findByPublicIdForOrgEnriched) would return findings from any org to any authenticated energia-entitled caller. Same footgun documented for bills in domains/utility/CLAUDE.md §SG-19. Consider removing from the exported object or renaming to signal unsafe scope.

conventions4

medium

Handler calls findingQueries directly, bypassing shell abstraction

apps/platform/src/api/handlers/finding.handler.ts:58

Both handlers call FindingFCIS.findingQueries.listByOrgEnriched and .findByPublicIdForOrgEnriched directly. The canonical form rule states: 'Handlers never call queries directly — always through shells.' For read-only paths this is common in practice, but the convention is to expose reads through thin shell-level functions rather than surfacing the raw findingQueries object. The queries ARE behind the FCIS namespace (borderline compliant), but a thin withFindingOrg() shell would enforce the pattern and enable future pre/post hooks.

low

insert query throws on null row instead of returning a Result

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

if (!row) throw FindingErrors.databaseError('insert') violates the FCIS rule 'never throw — return Result<T, E>'. The thrown value (a domain error object, not an Error instance) would bubble as an unhandled rejection through db.transaction(). The shell's comment acknowledges the case but the fix should be Result-based, not a throw — return null and let the shell map it to err().

low

mapFindingReadError accepts only FindingNotFoundError — the exhaustiveness guard is vacuous

apps/platform/src/api/mappers/finding.mapper.ts:44

The function parameter type is the single FindingNotFoundError, making the default: never branch unreachable by construction. The comment says this is intentional for PR2, but the never annotation misleads future readers into thinking the switch is genuinely exhaustive over a union. Use the full FindingReadError union as the parameter type so the switch IS exhaustive — adding a new read-path error then requires a new case.

info

FindingsListQuerySchema missing satisfies z.ZodType<FindingsListQuery> annotation

packages/api/src/schemas/finding.schemas.ts:139

All response schemas use 'satisfies z.ZodType<T>' per api-patterns.md. FindingsListQuerySchema is missing this annotation. A new filter field added to FindingsListQuery but forgotten in the schema would not be caught at compile time.

tests11

high

No integration test for shell's UPDATE path on re-detection with changed numbers

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

The idempotent double-run test only covers the noop path. There is no integration test that runs detectFindingsForOrgShell twice with different bill data (changed kwhPerDay, subtotal) and verifies value.updated===1 and the finding row is mutated correctly. The update path in finding.shells.ts is a currently untested branch — a column-mapping bug would let unit tests pass and the double-run noop test pass, but real re-detections silently fail to persist updated impact numbers.

high

Noop guard for non-null estimatedImpact (string→number round-trip) untested

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

Finding.estimatedImpact is string|null (Postgres numeric stored as string); UpsertFindingCommand.estimatedImpact is number|null. The noop comparison converts existing.estimatedImpact via Number() before comparing. The only noop test uses null for both sides. No test covers existing='100.00' vs command=100 (correct noop) or floating-point precision edge cases. If Number('100.000') !== 100 due to any rounding, every re-detection of a finding with non-null impact produces a spurious update and outbox event.

high

dismissed→detected re-open path via detectFindingsForOrgShell never integration-tested

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

decideUpsertFinding does NOT update status on re-detection (never-regress-status guarantee). A dismissed finding that re-fires should keep its status='dismissed'. There is no test that (a) inserts a finding, (b) transitions it to 'dismissed', (c) runs the shell again, (d) asserts status stays 'dismissed'. If the shell were ever changed to reset status, no test would catch the regression — a billing-domain invariant with no guard.

high

No unit test for isJsonEqual with nested arrays in details

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

The noop-with-key-ordering test only tests flat object reordering. The isJsonEqual implementation branches on Array.isArray. No test verifies array-valued details fields (e.g. {periods: ['2026-01','2026-02']}). An off-by-one in the array comparison branch would produce phantom updates on every re-detection run, emitting spurious outbox events.

medium

detectFpChronicPenalty: no test for 12-month window truncation

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

Tests only pass 3-4 rows. No test verifies that a 13th-month bill is dropped from the streak and annual-penalty calculations. A bug in the takeRecentPerRpu sort or slice would over-count the penalty — inflating estimatedImpact and possibly escalating severity to critical on billing-domain data.

medium

No integration test for fetchBillFactRowsForOrg XML-only source filtering

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

The existing test verifies source='pdf' bills are excluded, but not source='payment_check' or source='inferred'. These stub types must not appear in detector input. A refactor that accidentally widens the WHERE to OR source='inferred' would pass existing tests but feed fabricated historical periods to detectors, generating false billing-error findings.

medium

detectFpRegimeChange: no boundary test at the $1,000 threshold

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

The isDeadPaid predicate uses chargedSinceApr > 1000 (strict). Tests use 500 (clearly below). No boundary test at exactly 1000 (excluded) vs 1001 (fires). Standard practice for billing thresholds.

medium

detectConsumptionYoySpike: trendVsRecentPct (3-month proxy) never asserted

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

Tests assert yoyPct, severity, period, billId but never details.trendVsRecentPct. This field uses desc.slice(1,4) — a subtle off-by-one or slice boundary error would produce wrong/null trendVsRecentPct silently. While not used for severity, it is a customer-visible diagnostic field.

medium

detectInactiveServiceCharges: no boundary test at totalNet=$200

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

isDeadPaid requires totalNet > 200 (strict). Test uses 150. No boundary test at 200 (excluded) vs 201 (fires). A billing-critical threshold for 'dead meter' classification.

medium

No contract test for type filter vocabulary — 10 detector type strings untested

apps/platform/src/api/contracts/__tests__/finding.contract.test.ts:27

The type filter is a free-string (no closed enum at contract layer), so an unknown type silently returns 200+empty instead of 400. Tests only use 'estimated_reading'. None of the other 9 detector type strings are verified to round-trip through the filter correctly.

low

detectDapPresent: no test verifying that a DAP charge in month 13 (outside window) is excluded

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

detectDapPresent uses all.slice(0, 12). No test verifies that a DAP charge only in month 13 does NOT produce a finding, or that estimatedImpact reflects only the windowed months. If the slice were accidentally removed, impact would be inflated.

improvement4

low

FP_ELIGIBLE_TARIFFS is redundant — equals HT ∪ MT union

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

FP_ELIGIBLE_TARIFFS is provably identical to FP_LARGE_USER_HT_TARIFFS ∪ FP_LARGE_USER_MT_TARIFFS. A future addition to one of the sub-sets without adding it to FP_ELIGIBLE_TARIFFS would silently skip the recompute check for that tariff. Derive FP_ELIGIBLE_TARIFFS from the other two sets or remove it and inline the union check.

low

powerFactorBonusMxn fetched, mapped, typed — but read by no detector

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

BillFactRow declares powerFactorBonusMxn; the SQL query projects it and the query mapper maps it. No detector references it. If reserved for a future detector, add a comment. Otherwise, removing it reduces the SQL projection, type surface, and mapper noise — the BillFactRow contract should be honest about what detectors actually consume.

info

Handler auth prologue duplicated across list and get handlers

apps/platform/src/api/handlers/finding.handler.ts:41

Both handlers share a 4-step prologue: withAuth → !profileId → ensureModuleEntitled('energia') → resolveCurrentOrg. A small withEnergiaOrg(request, db, callback) helper would collapse ~10 lines into 1 per handler and make the entitlement+org-resolution always-paired. Low cost now (2 handlers), high value when write handlers (status transitions, notes) land in PR3+.

info

Math.min/max spread over qualifying array is fragile for very large arrays

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

Math.min(...pfs) / Math.max(...pfs) hit the JS argument-count limit on very large arrays (RangeError). In practice the fetch cap is 24 months and qualifying filters to post-2026-04 large users (tiny array). Defensive pattern: pfs.reduce((m,v) => Math.min(m,v), Infinity).

History · 3 commits

  1. 51f5a76needs attentionfull5H · 9M · 10L2026-07-21 23:04current
  2. c2e163aneeds attentionincremental1H · 7M · 7L2026-07-21 19:20
  3. a82f466needs attentionfull3H · 6M · 9L2026-07-14 20:06