feat/consumo-base
needs attention8f1462e · fullpre-PRreviewed 2026-07-23 02:29 UTC4H · 7M · 8L · 5I- 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[]
- 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+183−0domains/utility/src/consumption-series+862−0domains/utility/src/index.ts+1−0
- 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.
Findings · 23
correctness2
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.
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
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.
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).
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.
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.
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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).
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.