feat/int-base
needs attentionviewing older commite5674dc · incrementalPR #268reviewed 2026-07-09 19:29 UTC3H · 7M · 6L · 2I- Purpose
- Land every shared seam the 12 device-integration ports depend on — manifests, catalog seeds, ARN-seed registry, CDK path entries, and legacy-registry/credential migration scripts — so each per-brand branch only touches its own vendor directories.
- Goal
- All 12 brand integration manifests registered, seeded (Makes/Integrations/MetricTypes), CDK paths provisioned, and migration tooling (D2 credential copy + D3 registry migration) validated on preview DB. SG-7 adds `1M` calendar-month granularity end-to-end. SG-8 canonicalizes the multi-gran worker contract in INTEGRATION_STANDARDS.md §9.
- Sub-goals
- SG-1: 12 manifests + registration (DONE)
- SG-2: Catalog seed rows — Makes, Integrations, MetricTypes incl. battery/load (DONE)
- SG-3: ARN-seed REGISTRY entries + missing-Lambda tolerance (DONE)
- SG-4: CDK paths.ts handler entries (DONE)
- SG-5: D3 registry-migration script (DONE)
- SG-6: D2 credential-copy script (DONE)
- SG-N: Framework improvements — retire stale manifest co-location guidance (DONE)
- SG-7: 1M calendar-month granularity end to end (DONE)
- SG-8: Canonicalize multi-gran worker contract docs (DONE)
- What
- Two commits in this incremental window: (1) `a902ce33` — `Granularity` type gains `'1M'`; `monthSlotSeconds`/`energyKWhCalendarMonths`/`expectedCalendarMonths` pure functions added; `InputSeries` gains optional `calendarMonthly`; shell handles identity `5m` pipe read for monthly streams with timezone fallback; TOU breakdown excludes `1M` series; SQL case adds `'1M'→2592000`; Tinybird fixture + pipe test for identity read; tests for all new transforms + compute paths; (2) `e5674dce` — docs-only: INTEGRATION_STANDARDS.md §9 (canonical multi-gran worker contract), CLAUDE.md porting checklist, metrics-pipeline.md cross-refs, site-metrics-seam.md `1M` section.
- Why
- Vendors serving deep history (Growatt, several of the 12 brand ports) provide only monthly aggregates for historical periods. Without first-class `1M` support in the type system, Tinybird pipe, read coordinator, and integral, those sources cannot be ingested or used in savings analysis. The 12 brand branches need the contract (§9) before they can implement resilient multi-gran workers.
- Areas
- packages/integration-manifests+560−15packages/api+10−3packages/database+220−12domains/metrics+350−10domains/cross-domain+90−9infra/cdk/src/lib/paths.ts+185−0infra/tinybird+11−0services/metrics+50−20scripts/metrics+800−0.claude/rules+80−2
- Blast
- 55 files, +4206/−144 cumulative. Core impact: `@batu/integration-manifests` (Granularity type — all workers/schemas import this), `domains/metrics` (pure transforms + compute, read coordinator), `domains/cross-domain` (site-energy-metrics shell). Migration scripts are standalone CLIs (no runtime blast radius). CDK paths.ts is type-only until a brand stack references a path.
Findings · 17
correctness2
`max15min` degeneration for `1M` series is a silent lower-bound, no test contract
domains/metrics/src/site-metrics/compute.ts:93
For a `calendarMonthly` series `slotSeconds = 2_592_000`, so `rolling15minMeansW` computes `windowSamples = max(1, round(900/2592000)) = 1`. Each monthly point becomes its own 1-sample window — `max_demand_15min` returns the max monthly mean W/1000, a LOWER bound on the real peak. Downstream callers (GDMTH billing) that consume `max_demand_15min` from a `1M` source would systematically under-bill demand charges. The comment calls this out but no test pins the contract.
Splice invariant (1M disjoint from finer sources) has no runtime enforcement
domains/cross-domain/src/site-energy-streams.queries.ts:169
If a stream has both a `1M` source and a `5m` source with overlapping coverage, `min(grainCase)` resolves to `300`. The coordinator then sets `isMonthly = false` and integrates the `1M` point at `slotSeconds=300` — a ~8640× energy error. The seam doc designates this a data/provisioning responsibility, but no warning is emitted when the invariant is violated.
security3
Timezone from DB not re-validated at read time — invalid tz throws uncaught exception
domains/cross-domain/src/site-energy-metrics.shells.ts:205
`tz = streamsRes.value.timezone ?? 'UTC'` is passed directly to `monthSlotSeconds` / `energyKWhCalendarMonths` / `expectedCalendarMonths` → `date-fns-tz`. An invalid IANA string (direct DB write, seed error) throws an uncaught exception, bypassing the `Result` pattern. Defense-in-depth: wrap in try/catch and convert to a typed `SiteEnergyMetricsDatabaseError`.
No upper-bound on `[fromUtc, toUtc)` window in `expectedCalendarMonths`
domains/metrics/src/site-metrics/transforms.ts:198
The while-loop iterates month-by-month with no cap. A very large window (e.g. multi-decade query) would spin tens of thousands of iterations synchronously on the Lambda thread (CPU exhaustion / timeout). A `count > MAX_MONTHS` guard or enforcing a max window at the handler layer would add defense-in-depth.
`GranularitySchema` regex permits `0m`, `0s`, `0d` (zero-width granularities)
packages/api/src/schemas/metrics-common.schemas.ts:33
The regex `/^(\d+(s|m|h|d)|1M)$/` accepts `0m`, `0s` because `\d+` matches `0`. These map to `granularitySeconds = 0 → null` in the SQL CASE, producing silent no-data rather than a validation error. A `.refine(g => g === '1M' || parseInt(g) > 0)` would close this.
conventions4
New `console.warn` calls in shell expand a pre-existing convention gap
domains/cross-domain/src/site-energy-metrics.shells.ts:181
The 1M branch introduces two new `console.warn` calls (lines ~181, ~208) for missing-timezone and unsupported-granularity conditions. `domain-patterns.md` mandates `createShellLogger(domain, operation, actor)` in shells. The calls are suppressed by eslint-disable comments — an acknowledged workaround. Not a new violation but the PR expands the footprint without relocating to structured logging.
`GranularitySchema` missing `satisfies z.ZodType<Granularity>` alignment check
packages/api/src/schemas/metrics-common.schemas.ts:32
`Granularity` type now includes `'1M'` and `GranularitySchema` was updated to match. Adding `satisfies z.ZodType<Granularity>` after the schema would catch any future drift between the TypeScript type union and the runtime regex at compile time — the canonical-form pattern for schema alignment.
`window` parameter in `computeOutputs` shadows the JS global
domains/metrics/src/site-metrics/compute.ts:67
The optional param `window?: { fromUtc, toUtc }` shadows the browser `window` global. In Node/Lambda this is harmless, but it is a readability trap and may produce linter warnings with DOM types. `readWindow` or `bounds` avoids the ambiguity with zero functional cost.
`metricsForPeriod` mutates `Record` after `computeOutputs` returns it
domains/cross-domain/src/site-energy-metrics.shells.ts:337
`metricsForPeriod` assigns `computeOutputs(...).metrics` to `const metrics`, then mutates it (`metrics['max_demand_15min'] = ...`), then returns it as `Readonly<Record<string,number>>`. TypeScript allows this (mutable assignable to Readonly alias) but it violates the immutable-returns convention. Prefer `return { ...metrics, max_demand_15min: band15minKW[period] }` for clarity.
tests7
Shell `isMonthly` path has no unit or integration test
domains/cross-domain/src/site-energy-metrics.shells.ts:173
The new block that detects `granularitySeconds === MONTH_GRANULARITY_NOMINAL_SECONDS`, selects `MONTHLY_IDENTITY_INTERVAL`, and tags the series `calendarMonthly: { timezone }` is exercised by no test. A bug in the detection (off-by-one on the sentinel, wrong interval choice, or timezone fallback logic) would be invisible until a live `1M` stream is queried.
UTC timezone fallback path for site-with-no-location untested
domains/cross-domain/src/site-energy-metrics.shells.ts:204
When `streamsRes.value.timezone` is `null`, the code falls back to `'UTC'` for the calendar-month integral. No test verifies (a) the fallback fires without throwing, (b) the computation proceeds, or (c) the result uses UTC month widths. A site without a `site_locations` row would silently integrate with wrong widths.
`max_demand_15min` degeneration for `1M` series unverified
domains/metrics/src/site-metrics/__tests__/compute.test.ts:64
`compute.ts` calls `max15minDemandKW(points, slotSeconds=2_592_000)` for calendar-month inputs, which degenerates to `max(monthly mean W)/1000` (documented as a lower bound). No test pins this value or asserts the key is present/absent. A future guard that drops `max_demand_15min` for monthly series would be invisible.
Year-boundary rollover not tested in `expectedCalendarMonths`
domains/metrics/src/site-metrics/__tests__/transforms.test.ts:159
All three `expectedCalendarMonths` test cases use intra-year windows (Jan–Apr 2026). The `advance()` branch for `month === 12` (year increment, month reset to 1) is never exercised. A window like `[2026-11-01, 2027-02-01)` should return 3 but the year-rollover path is untested. Same gap exists for `monthSlotSeconds` on a December point.
No test of stream resolution with `granularitySeconds = 2_592_000` (1M nominal)
domains/cross-domain/src/site-energy-streams.queries.ts:169
The SQL `when '1M' then 2592000` mapping is new. No test verifies that a `1M` stream arrives at the coordinator with `granularitySeconds === 2592000`. Existing `projectSiteEnergyStreams` tests only use `300` and `900` — a typo in the sentinel constant or the SQL case would go undetected.
Tinybird pipe test covers CDMX only; Tijuana DST month-start not exercised
infra/tinybird/tests/resolve_stream_window.yaml:39
The `monthly_1M_identity_read_at_300s` fixture uses CDMX UTC-6 (month-start at `06:00Z`). Tijuana is UTC-8 in winter (`08:00Z`), UTC-7 in summer (`07:00Z`). The claim '300 s flooring is lossless for any IANA offset' is mathematically sound but a Tijuana case in the fixture would make it proof-complete, since the seam doc specifically calls out Tijuana DST handling.
`energyKWhCalendarMonths` gap-month-zero test only checks empty array
domains/metrics/src/site-metrics/__tests__/transforms.test.ts:154
The 'missing months contribute 0' test is `energyKWhCalendarMonths([], CDMX) === 0`. The contract that non-contiguous points (e.g. Feb + May with Mar/Apr missing) contribute only for present months is never stressed — all multi-point tests have contiguous months.
improvement1
`expectedCalendarMonths` recomputes `localToUtc(monthStartLocalStr(...))` twice per iteration
domains/metrics/src/site-metrics/transforms.ts:194
The initial alignment check and the while-loop condition both call `localToUtc(monthStartLocalStr(year, month), timezone)` for the same (year, month) before each `advance()`. Caching the anchor — `let anchor = localToUtc(...); while (anchor.getTime() < ...) { count++; advance(); anchor = localToUtc(...); }` — halves the calls and removes the structural divergence risk.
History · 11 commits
- f7b9554safeincremental0H · 0M · 0L2026-07-13 20:18
- 9ca23adneeds attentionincremental0H · 3M · 3L2026-07-13 19:17
- 595484fneeds attentionincremental1H · 3M · 7L2026-07-13 04:29
- 2ed62f4safeincremental0H · 0M · 0L2026-07-13 04:13
- 2812f54needs attentionincremental1H · 3M · 10L2026-07-11 00:19
- 1794b23needs attentionincremental2H · 2M · 5L2026-07-10 21:59
- e5674dcneeds attentionincremental3H · 7M · 6L2026-07-09 19:29current
- ca45a96needs attentionincremental0H · 3M · 3L2026-07-09 18:56
- 5d19484safeincremental0H · 0M · 1L2026-07-08 02:04
- dd403feneeds attentionincremental12H · 22M · 12L2026-07-07 20:02
- e663ae9needs attentionincremental2H · 7M · 7L2026-07-07 19:02