← all branches

feat/consumo-base

needs attention
8f1462e · fullpre-PRreviewed 2026-07-23 02:29 UTC4H · 7M · 8L · 5I
The branch
Purpose
Produce a clean monthly kWh series per CFE contract from raw bills, replacing the naive BillFactRow projection. Prerequisite for consumption detectors (peer-benchmark, load-factor, tariff migration). Pure read-path.
Goal
consumption-series base — clean kWh series (PR #1 of multi-PR feature)
Sub-goals
  • SG-1: Type definitions for ConsumptionBillInput, ConsumptionMonth, BillClass (type.ts)
  • SG-2: Discriminated-union errors with _tag + statusCode (errors.ts)
  • SG-3: Pure buildConsumptionSeries decision function — classify, day-ownership dedup, allocate, merge (decisions.ts)
  • SG-4: Org-scoped query fetching eligible bills by conceptId (queries.ts)
  • SG-5: FCIS barrel and domain index re-export (index.ts)
  • SG-6: Unit tests for all spec edge cases (decisions.test.ts)
  • SG-7 (PR #2): Wire detectors (detectConsumptionYoySpike, detectEstimatedReadings) to ConsumptionMonth[]
The changes (whole branch)
What
Adds the full consumption-series FCIS module to domains/utility: 5 source files + 1 test file + 1 spec doc. 1063 lines added. No schema changes, no migrations, no outbox events.
Why
Existing BillFactRow projection (fetchBillFactRowsForOrg) produced false spikes (+79% artifact), Axo inflation, and estimation double-counts because it did not normalize bimonthly periods, catch-up adjustments, or estimation bills. v2 introduces a day-ownership model that structurally prevents all three biases.
Areas
docs/energia+1830domains/utility/src/consumption-series+8620domains/utility/src/index.ts+10
Blast
8 files, +1063/−0. Pure additive — no existing code changed except one line added to the domain barrel. Zero risk of regressions in other features.
PR #2 (detectors) depends on this — do not merge in wrong order The flat org-wide query result must be grouped by contractId before calling buildConsumptionSeries No integration test yet — spec §10 verification criteria (KK reference) unvalidatable in CI
ci· No open PR — push-triggered review before PR creationcoderabbit· No .coderabbit.yaml in repo

Findings · 23

correctness2

high

contractId/rpu stamped from prepared[0] onto ALL output months

domains/utility/src/consumption-series/consumption-series.decisions.ts:234

const first = prepared[0]!.input captures contractId and rpu from the first bill and writes them onto every ConsumptionMonth. fetchConsumptionBillInputsForOrg returns a flat org-wide array (multiple contracts). A caller that passes the raw result gets every month labelled with the wrong contract identity — silent corruption, no error signal. Fix: store contractId/rpu on MonthAcc from the owning bill, or add an early guard that rejects mixed-contract input.

medium

Window keyed on bill issue date (year_month), not period coverage

domains/utility/src/consumption-series/consumption-series.queries.ts:113

DENSE_RANK() OVER (PARTITION BY rpu ORDER BY year_month DESC) windows by when the bill was issued, not what period it covers. A retroactive adjustment issued in month 37+ is excluded even if its period falls inside the 36-month window. The adjustment cannot then displace the estimated reading it was meant to correct, causing a silent accuracy regression. Low urgency under normal CFE cadences; materialises on accounts with long unread periods.

security7

medium

No shell wrapper — query exported directly, inviting handler→query bypass

domains/utility/src/consumption-series/index.ts

fetchConsumptionBillInputsForOrg is exported at the top-level barrel. ADR-016 requires handlers to call shells only. A future PR #2 handler could call this directly, bypassing any future transaction boundary or audit trail. Even as a pure read-path, wrapping in a read-shell (or at minimum gating the export behind ConsumptionSeriesFCIS namespace) prevents the violation.

medium

No createRLSDb wrapping — known utility domain gap, must be documented in PR #2 handler

domains/utility/src/consumption-series/consumption-series.queries.ts

The utility domain runs via raw database (service-role, RLS bypassed) due to missing entity_relationships rows for pipeline-created contracts. The manual orgId scoping in the query IS correct (SG-19 pattern). But when PR #2 wires a handler, the handler author must explicitly document the RLS bypass and rely on resolveCurrentOrg as the sole access gate. Omitting this comment risks a future 'fix' that re-applies createRLSDb and returns empty results (regression pattern documented in utility CLAUDE.md, commit 8b967a38).

low

orgId accepted as plain string with no UUID format guard in query layer

domains/utility/src/consumption-series/consumption-series.queries.ts

The ::uuid cast in SQL surfaces malformed ids as a 500 (22P02) rather than a 400. In practice orgId comes from JWT claims (trusted), but the function is now a public export. PR #2 handler author must validate orgId is a UUID before passing it, mirroring how other handlers use resolveCurrentOrg.

info

Org scoping is correct — canonical SG-19 pattern applied

domains/utility/src/consumption-series/consumption-series.queries.ts

org_scoped_sites anchors on sites.org_id = orgId::uuid, org_contract_ids sub-selects through site_utility_contracts, and the main WHERE adds uc.id IN (org_contract_ids). This is exactly the three-level anchoring pattern the domain CLAUDE.md prescribes. No cross-org bill leak is possible from the query.

info

No SQL injection risk — all interpolations are Drizzle parameterized

domains/utility/src/consumption-series/consumption-series.queries.ts

orgId uses ${orgId}::uuid (Drizzle tagged template → $1 placeholder). FETCH_WINDOW_MONTHS is a module-level constant. JSONB operator strings are SQL literals. No runtime string concatenation.

info

Pure core has no cross-org leak surface

domains/utility/src/consumption-series/consumption-series.decisions.ts

buildConsumptionSeries is a fully pure function. No I/O, no user-controlled values reach SQL or file paths. billId appears only in Result error values, not HTTP responses.

info

contractId (internal UUID) in ConsumptionMonth type — response mapper in PR #2 must map to publicId

domains/utility/src/consumption-series/consumption-series.type.ts

ConsumptionMonth carries the raw internal UUID contractId. PR #2 handler must map to contractPublicId (the uct_-prefixed id) in the response mapper, consistent with how other handlers expose contracts.

conventions4

medium

Module barrel (index.ts) does not export ConsumptionSeriesFCIS namespace

domains/utility/src/consumption-series/index.ts

The domain barrel (domains/utility/src/index.ts) does export * as ConsumptionSeriesFCIS from './consumption-series', but the module's own index.ts only exports flat named symbols. Canonical-form.md requires the FCIS namespace to be self-contained in the sub-module barrel so both import paths are equivalent. Fix: export const ConsumptionSeriesFCIS = { buildConsumptionSeries, classifyBill, consumptionSeriesQueries, ConsumptionSeriesErrors } from index.ts.

low

fetchConsumptionBillInputsForOrg double-exported alongside consumptionSeriesQueries

domains/utility/src/consumption-series/index.ts:17

index.ts exports both the consumptionSeriesQueries object AND the individual function as a named export. domain-patterns.md prescribes exporting the object namespace only. Callers have two paths with no guidance; the pattern will grow inconsistently as more query functions are added.

low

No integration test for the query layer

domains/utility/src/consumption-series/__tests__/

No consumption-series.queries.integration.test.ts. The org-scoping, period_end >= period_start guard, source='xml' filter, and JSONB LATERAL join by conceptId are exactly the classes of bugs unit tests cannot catch. The spec called for one (§9); it self-skips without POSTGRES_URL so there is no CI cost.

info

No mapper.ts — acceptable for derived DTO

domains/utility/src/consumption-series/

canonical-form.md lists mapper.ts. Omitted here because ConsumptionMonth is a derived DTO with no backing Drizzle table. Explicitly documented in spec §2. SQL→domain mapping is inlined in queries.ts .map(). If it grows, extract to consumption-series.mapper.ts.

tests6

high

kwh=0 bill is NOT skipped — claims day ownership, month not flagged partial

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

The null-kwh guard uses loose equality (b.kwh == null) which lets kwh=0 through. A zero-kWh bill passes preparation, claims day ownership (possibly displacing a real bill), contributes 0 to the month's kWh total, and its days count toward daysCovered — so the month is NOT flagged partial. CFE emits CONSUMO_R=0 on estimated zero-consumption months. Behavior (treat as valid zero-reading or skip like null) is an untested policy decision.

high

Two measured bills with same periodEnd — iteration-order wins, no declared policy

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

When two bills share the same class AND the same periodEnd, the tie-break condition (p.endMs > cur.periodEndMs) is never true; the first bill in the array retains ownership. This is iteration-order determinism, not a declared policy. CFE can reissue a corrected measured bill for the same period (meter swap mid-month). No test asserts a defined winner; a future query sort-order change silently changes which bill owns the month.

high

Two overlapping adjustment bills — newer should win, never tested

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

CFE can issue a second catch-up adjustment superseding an earlier one. The tie-break promotes the bill with the later periodEnd within the same class. This is correct behavior but is untested. A regression changing > to >= in the tie-break condition would silently cause double-allocation from both overlapping adjustments — with no test failing.

medium

Mixed per-period coverage suppresses perPeriod silently — behavior unasserted

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

When a month has some days from a band-carrying bill and others from a no-band bill, daysWithPerPeriod < daysCovered and perPeriod is suppressed. The accumulated base/intermediate/punta values are silently discarded. No test covers the mixed-band case: asserts perPeriod undefined AND kwh still correct. A refactor changing the suppression condition would pass all tests while emitting garbled per-period data.

medium

All-estimated full-coverage month: source field reported as measured — unasserted

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

The source logic sets 'adjustment' if anyAdjustment else 'measured' — no path for a purely-estimated month. A fully-estimated month reports { source: 'measured', confidence: 'low' }, which reads as 'a real reading we are not confident in' rather than 'an estimate placeholder'. The existing confidence test does not assert the source field. Consumers filtering on source would incorrectly include estimated months.

low

Unparseable date (e.g. 2026-02-30) exercises round-trip guard — untested

domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts

The defensive guard test covers an inverted period but not an impossible date like Feb 30. Date.UTC(2026, 1, 30) normalises to a real date; the round-trip check catches it. That code path is currently dead in tests. CFE XML with a malformed date would silently fall to the else-return-null branch.

improvement4

medium

toNumberOrNull duplicated from finding.queries.ts — belongs in domains/utility/src/lib/

domains/utility/src/consumption-series/consumption-series.queries.ts:41

toNumberOrNull is defined byte-for-byte identically in both finding.queries.ts and consumption-series.queries.ts. The utility domain already has domains/utility/src/lib/ with formatters.ts, billing-cycle.ts, etc. A third caller will define it a third time. Extract once.

low

monthKeyOf and daysInMonthOf are utility-grade helpers likely to be re-invented

domains/utility/src/consumption-series/consumption-series.decisions.ts:72

Both helpers are private and unexported. The metrics domain already has analogues (monthSlotSeconds, expectedCalendarMonths). PR #2 detectors will need YYYY-MM arithmetic. Extracting to domains/utility/src/lib/ now prevents a third re-invention.

low

buildConsumptionSeries silently accepts mixed-contract input — no assertion or JSDoc warning

domains/utility/src/consumption-series/consumption-series.decisions.ts:128

The function is documented as 'ONE contract's bills' but has no guard against mixed input. When fetchConsumptionBillInputsForOrg returns a flat org-wide array, callers must group by contractId before calling. A JSDoc warning and/or an early err() check would prevent the silent contractId/rpu corruption (see correctness finding #1).

info

kwhPerDay on mixed-bill month is a weighted average of rates — semantics undocumented

domains/utility/src/consumption-series/consumption-series.decisions.ts:251

When a month is owned by days from two bills with different kwhPerDay rates, acc.kwh / acc.daysCovered is an arithmetic weighted average. Correct per spec §4.3 (kwh / daysCovered), but the type comment on ConsumptionMonth does not note this averaging behavior, which may surprise consumers comparing against single-bill BillFactRow rates.