Skip to content

feat(carbonInventory)!: make the server authoritative over catalog factor snapshots - #628

Closed
mrivas00 wants to merge 26 commits into
feat/mati/emission-factor-year-schemafrom
feat/mati/emission-factor-server-authority
Closed

mrivas00 wants to merge 26 commits into
feat/mati/emission-factor-year-schemafrom
feat/mati/emission-factor-server-authority

Conversation

@mrivas00

@mrivas00 mrivas00 commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

What & why

  • The line-sync payload let the client author a catalog factor's value, source and year. Nothing stopped a stale editor state — or a crafted request — from being persisted as an authoritative "catalog" snapshot.
  • Now the client sends only the canonical factor id plus the unit it wants the value in; the server re-reads the row, validates it belongs to the inventory, converts in decimal arithmetic and writes the snapshot.
  • Second half of the user-facing feature: capture ranks vintages against the footprint year, and the summary flags lines whose applied year no longer matches.
  • Base: feat/mati/emission-factor-year-schema. Plan: openspec/changes/add-emission-factor-year/.

Risk / blast radius — read first

  • Breaking (API contract): POST /api/carbon-inventories/:id/lines/sync replaces the ambiguous factor fields with a discriminated union — CATALOG { emissionFactorId, appliedRateMeasurementUnitId }, CUSTOM { source, value, rateMeasurementUnitId }, DIRECT { totalEmissions }. Web is adapted in the same PR because the old and new shapes cannot both type-check; the front and the back must land together.
  • Client-authored catalog value/source/year are removed from the write path. A request that still sends them is rejected, not silently honoured.
  • No schema changes here — the columns arrived with the previous PR.

Key changes & decisions

1. Discriminated union instead of inferring the factor kind from the source string

syncCarbonInventoryLines/schemas.ts, types.ts (+3) · 8f0134e

  • Trigger — "is this a custom factor?" was answered by string-matching source against a constant list.
  • Change — the request states its own kind; CUSTOM and DIRECT keep their dedicated validations, and custom lines persist emissionFactorId = null / appliedFactorYear = null.
  • Why this way — the string heuristic breaks the moment a real provider is named like a custom source, and it made "catalog" the accidental default. An explicit tag makes the invalid combinations unrepresentable instead of merely unlikely.

2. Server re-derives value, source and year inside the sync transaction

syncCarbonInventoryLines/helper.ts, service.ts (+6) · cb6beed

  • Change — the factor is loaded in-transaction and validated for ACTIVE status, methodology/subcategory membership, required dimension values and applied-unit family compatibility before anything is written.
  • Why this way — validating outside the transaction leaves a window where a factor is soft-deleted between check and write. Re-deriving rather than trusting the payload is what makes the snapshot mean "what the catalog said", which is the whole point of storing it.

3. The conversion runs in decimal arithmetic, not floating point

getCarbonInventoryMethodology/helper.ts, syncCarbonInventoryLines/helper.ts · 5598beb, 8e1e760

  • Trigger — converting through number silently rounded factor magnitudes.
  • Why this way — the stored column is Decimal(28,10); doing the arithmetic in Prisma.Decimal end-to-end is the only way the persisted value matches the catalog exactly. 8e1e760 pins it with a case a double loses.

4. Vintage ranking: exact year → transversal → nearest earlier → nearest later

EmissionEditor/services/emissionFactorService.ts (+9) · 44ed569

  • Change — pure ranking helpers; a recommendation is returned only when the winning rank holds exactly one canonical factor. A tie returns all winning candidates and selects nothing.
  • Why this way — breaking a tie by array order, source or id would silently pick a provider on the user's behalf and look deterministic while depending on query order. Making the tie visible is the honest failure mode.

5. One Factor control, not a separate vintage selector

EmissionEditorFactorSourceCell.tsx, useEmissionEditorColumns.tsx (+2) · 44ed569

  • Change — dated options render as DEFRA (2025), transversal ones show only the source; the existing Otro option and its custom-factor fields stay in the same list.
  • Why this way — year and source are two attributes of a single choice; a second dropdown would let the user build a combination that does not exist in the catalog and force a reconciliation step.

6. Reload restores the snapshot, not the current recommendation

useEmissionCaptureData.ts, emissionCaptureTransformers.ts (+2) · 44ed569

  • Why this way — re-running the ranking on load would quietly replace a deliberate historical choice whenever the catalog gains a newer vintage. The saved factor id wins; the ranking only ever proposes for an unset line.

7. Mismatch warning is derived, informational, and per subcategory

factorYearMismatch.ts, useEmissionFactorsColumns.tsx (+4) · bb2a6dc

  • Change — one warning per subcategory with affected/eligible counts, the distinct mismatching years and the current inventory year, plus explicit wording that nothing was recalculated. Transversal factors, custom factors, direct totals and inactive lines are excluded from both counts.
  • Why this way — computed from the line snapshots at read time (decision 3 of the plan), so it stays correct after an inventory-year change without storing a flag. It never blocks save, navigation or submission, and there is deliberately no bulk re-resolution action: preserving the user's choices matters more than making the warning disappear.

Test plan
Automated gates that actually ran on this branch:

  • pnpm format:check && pnpm lint && pnpm type-check — green.
  • pnpm test:api -- /carbonInventories --coverage=false — 549/549 (includes the new catalogFactorAuthority suite).
  • pnpm test:api -- /emissionFactors --coverage=false — 60/60.
  • pnpm test:web — 870/870. Remaining api domains run in the CI matrix.

Manual, by risk — not run in this session:

  • Capture, inventory year 2026, subcategory with a 2025 and a 2026 factor → 2026 preselected; delete the 2026 vintage → 2025 proposed as nearest earlier; two sources tied on the winning year → nothing preselected.
  • Save a catalog line, then replay the same sync request with a tampered factor value → rejected, and the stored applied value still equals the catalog row.
  • Save a 2025 line on a 2026 inventory → summary shows one warning for that subcategory with counts and years; save, navigate and submit all still work; totals unchanged.
  • Change the inventory year, then reload → warning recomputes, no factor or result is rewritten. Duplicate the inventory → appliedFactorYear copied verbatim.

Chain — merge in order:

  1. docs(openspec): plan emission factors by reporting year #626 — openspec plan — decisions, specs, rollout (docs only)
  2. feat(emissionFactors)!: identify factors by reporting year and unit family #627 — schema, catalog identity, maintainer year
  3. feat(carbonInventory)!: make the server authoritative over catalog factor snapshots #628 — server-authoritative snapshots, vintage ranking, mismatch warning ← this PR
  4. feat(carbonInventory): keep a line's stored factor when the request says unchanged #629 — UNCHANGED variant + batched catalog lookups

Out of scope

  • The UNCHANGED selection variant and the batched catalog lookups → feat/mati/emission-factor-unchanged-selection.

@mrivas00 mrivas00 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — catalog factor server authority

Reviewed the full diff of this PR: 15 findings, 9 of them inline below. The rest are on lines this PR does not touch (or belong to another PR of the chain), so they are grouped here.

Findings on lines outside this diff

[media] apps/api/src/features/carbonInventories/getEmissionFactors/service.ts:98 — rows are deduplicated by catalog emissionFactor.id alone, but appliedFactorYear / appliedFactorValue are per line, so once a factor is re-dated the lines that applied different vintages collapse into a single row and the new "Año del factor" column under-reports the mismatch. Fix: deduplicate on (emissionFactorId, appliedFactorYear, appliedFactorValue) so each applied vintage gets its own row.

[media] apps/web/src/screens/CarbonInventory/components/EmissionEditor/hooks/useEmissionEditorForm.ts:149 — resetFactorValueFields / resetFactorRelatedFields clear baseFactorId, factorValue, factorRateMeasurementUnitId and factorSource but not the two fields this PR adds (emissionFactorId, appliedFactorYear), so a line left with no factor (unit change with no unique recommendation) keeps triggering the year-mismatch alert. Fix: clear both fields in the resets, or drop the baseFactorId / emissionFactorId duplication on EmissionCaptureFormLine so they cannot diverge.

[baja] apps/web/src/screens/CarbonInventory/components/EmissionEditor/services/emissionFactorService.ts:48 — getAvailableSources has no callers left after the move to ID-keyed selection, and apps/api/src/utils/index.ts:7 still re-exports CUSTOM_FACTOR_SOURCES, which the API no longer imports. Fix: delete both, so the source-as-identity idiom this PR removes is not left lying around.

Belongs to the schema PR of the chain (feat/mati/emission-factor-year-schema), not to this one

[alta] packages/database/src/prisma/migrations/20260901120000_add_emission_factor_year/migration.sql:46 — the preflight hard-codes the four source strings of the seed dataset and raises on anything else, but the maintainer source cell is a free-solo input, so any real database (the on-prem deployment included) aborts the migration and blocks prisma migrate deploy. Fix: default unknown sources to a transversal year (NULL) and report them, keeping the exception for genuine data inconsistencies.

[alta] packages/database/src/prisma/migrations/20260901120000_add_emission_factor_year/migration.sql:142 — conflict detection compares numeric division results via count(DISTINCT base_value), and two mathematically equivalent factors expressed in different units differ in the last digit, aborting the migration over consistent data. Fix: round to a fixed scale (or apply an explicit tolerance) before the DISTINCT.

[media] apps/web/src/screens/Maintainer/hooks/useEmissionFactorColumns.tsx:510 — the year cell reuses EditableNumberCell, which maps any unparseable input to null, and null here means "transversal, applies to every reporting year", so a typo silently redefines the factor's semantics and makes it win the TRANSVERSAL rank for every inventory year. Fix: use a dedicated year cell that distinguishes an intentionally empty value from an invalid one.

Comment thread apps/api/src/features/carbonInventories/syncCarbonInventoryLines/service.ts Outdated
Comment thread apps/api/src/features/carbonInventories/syncCarbonInventoryLines/helper.ts Outdated
Comment thread apps/api/src/features/carbonInventories/syncCarbonInventoryLines/helper.ts Outdated
Comment thread apps/web/src/screens/CarbonInventory/hooks/useEmissionCaptureData.ts Outdated
Comment thread apps/api/src/rest/carbon-inventories-syncLines.rest
Comment thread apps/web/src/utils/exportCarbonInventoryToExcel.test.ts Outdated
Comment thread apps/web/src/screens/CarbonInventory/utils/factorYearMismatch.ts
Comment thread apps/api/src/features/carbonInventories/syncCarbonInventoryLines/helper.ts Outdated
Comment thread apps/api/src/features/carbonInventories/getCarbonInventoryMethodology/helper.ts Outdated
Comment thread apps/api/src/features/carbonInventories/syncCarbonInventoryLines/service.ts Outdated
@mrivas00
mrivas00 force-pushed the feat/mati/emission-factor-server-authority branch from d298a0d to 96a9436 Compare September 10, 2026 12:24
Line sync branches on the discriminated factor selection instead of guessing
from the source string. For a CATALOG selection the factor is loaded inside the
sync transaction — so the row cannot be edited or deleted between validation and
the write — and four things are checked: it is ACTIVE, it belongs to this
inventory's methodology version and the line's subcategory, its required
dimension values match the line, and the requested applied unit shares its
magnitude family. A conversion across families is not a conversion, so that is a
rejection rather than a best effort.

Value, source, year and the conversion into the requested unit are then read and
computed from the row, and the line result follows from the converted value.
Client-authored value, source and year are gone from the write path: the CATALOG
variant is strict, so a request that tries to supply them is refused outright.

An updated line's subcategory comes from the stored line rather than the request,
so catalog validation is always scoped to the line it belongs to.

CUSTOM keeps its own path and persists `emissionFactorId = null` and
`appliedFactorYear = null`; DIRECT gets no factor snapshot at all. That is what
keeps both out of the year-mismatch warning by construction. Inventory
duplication copies `appliedFactorYear` with the other immutable snapshots — never
re-resolved, so re-dating the copy changes nothing.
`appliedFactorYear` is a historical snapshot: it records the vintage applied when
a line was saved and is never rewritten, not when the catalog is edited and not
when the footprint year changes. So whether a line disagrees with the current
footprint year is derived, from pure helpers, at render time — a stored flag would
go stale on the first re-dating.

The factors-used summary gains its own year column. A null applied year is a
transversal or custom factor, neither of which has a vintage that can disagree
with anything, so both render as an em dash with an explanation rather than being
styled as a problem. A real mismatch is marked, and its tooltip says the
calculation was not modified.

The helpers are shared rather than local to the summary because the capture
screen needs exactly the same rule for its subcategory warning.
The Factor selector was keyed on source text, which cannot tell two vintages of
one provider apart, and auto-loaded a factor whenever the activity had exactly
one source. It is now keyed on the canonical factor ID, and ranks candidates
against the footprint year: exact year, then transversal, then nearest earlier
year, then nearest later. Earlier beats later because a factor published before
the footprint year describes conditions closer to it.

A factor is preselected only when the winning rank holds exactly one canonical
factor. Two providers publishing for the same year are two defensible scientific
choices and picking between them belongs to the organization, so array order,
source text and factor ID are never used as tie-breakers. Candidates are
deduplicated by canonical ID first, or a factor available in many units would
look like a tie with itself.

It stays one control labelled `Factor`, with no separate year selector: a dated
option reads `DEFRA (2025)` and a transversal one just `IPCC`, since the
parentheses are what mark a factor as dated. `Otro` keeps its place in the same
list. A saved choice is restored by ID on reload and is never replaced by a newer
recommendation.

Saving sends only the canonical ID and the desired unit. The subcategory warning
recomputes from the current rows on every render, so it follows a reload, a single
factor edit and a footprint-year change with no invalidation step — and it blocks
nothing.
The write path took a Decimal(28,10) through Number.parseFloat before storing it
as the line's applied factor, so a value beyond JavaScript's safe precision was
rounded — and that rounded number is what the stored emission is computed from.

convertEmissionFactorValueDecimal is now the real conversion; the string variant
the read path uses delegates to it and only stringifies the result, so the two
can no longer drift and neither turns the value into a double.
…tract

Every request in the file still sent factorSource, baseFactorId,
appliedFactorValue, appliedFactorRateMeasurementUnitId and manualTotalEmissions,
and none sent inputType or factorSelection. The schemas are strict, so all of
them were rejected with a 400 — the file documented a contract that no longer
exists.

Rewritten around the four selection variants, with a header explaining what each
one means and why a catalog selection deliberately omits value, source and year.
Adds an UNCHANGED example, a catalog-factor rejection, and the create that must
fail because UNCHANGED is not part of the create union.

carbon-inventories.rest keeps its stale line examples: they describe a PATCH
/lines endpoint that no longer exists at all, which is a separate cleanup.
The overflow case asserted float behaviour: 1e300 * 1e300 * 1e300 became
Infinity and was rejected. In decimal arithmetic it is simply computed, so the
test now pins the exact result instead.

Adds the case that motivated the change — a value with more significant digits
than a double holds, which parseFloat rounded away on its way into the stored
snapshot.
mapFactorSelection tested the source text before baseFactorId, so a catalog
factor whose provider is named like the custom-factor label was submitted as a
CUSTOM selection with the client's own value — bypassing the catalog identity,
the year snapshot and every server-side check.
resetFactorValueFields cleared baseFactorId but not emissionFactorId or
appliedFactorYear, so a line left with no factor — a unit change with no unique
recommendation — kept reporting a year mismatch against a factor it no longer
had.
A retired factor drops out of the methodology payload, so the saved
baseFactorId matched no option and the Select rendered blank — the same as a
line that never had a factor, with an out-of-range warning in the console. The
cell now renders the state explicitly and keeps the value unselectable.
The selector treated a factor's blank dimension as a wildcard while the API
requires an exact match on every required position, so a factor left blank
there was offered — and could even be recommended — only for the save to fail
with a dimension mismatch.
The conversion threw CatalogEmissionFactorNotFoundError with a rate-unit id, so
the user was told their emission factor no longer exists — naming the wrong
entity, the wrong id, and asking them to reselect a factor that is fine.
The tagged union makes the pair representable, and nothing checked it: a
SIMPLIFIED line carrying a DIRECT total persisted the total with no result row
— a number in the editor that counts as zero everywhere — while a DIRECT line
carrying a catalog selection wrote a factor snapshot no result reads.
The summary read the source from the live catalog row while reading the year
from the line snapshot, so a renamed factor produced a provider/vintage pair
that never existed — in the table and in the Excel export alike.
Rows were deduplicated by catalog factor id, but the year and the value are
snapshotted per line: once a maintainer re-dates a factor, the lines holding
the older vintage collapsed into the newer row and their year warning
disappeared from the summary.
Moving the persisted conversion to Decimal dropped the finiteness check on the
result, and nothing bounded it against the column it is written to. A
non-finite result is now rejected for both callers, and a value too large for
applied_factor_value fails as a typed data error instead of a numeric overflow
mid-transaction.
Each catalog line ran its own required-dimension query and two rate-unit
queries, all of them repeats within a subcategory save: forty lines meant
around a hundred and sixty sequential round-trips inside the transaction,
against Prisma's five-second budget. The shared part is now read once per
request and the rate units are cached as they are seen.
The row id joins the factor, its applied year and its applied value; a
transversal factor has no year, and an empty segment made the id read as a
typo. It says "transversal" instead.
Two lines holding two vintages of one re-dated factor must stay two rows; the
existing assertions on the row id move to the vintage-keyed form.
With one affected line and one vintage the alert read "1 de 1 línea … usan
factores de 2020": the verb and the factor phrase stayed plural, and the test
asserted the ungrammatical form.
The summary table gained an "Año del factor" column and the workbook did not,
so the copy people send to a reviewer was the one without the vintage — the
place a stale factor matters most.
The required-dimension query filtered on the literal "ACTIVE" while the query
right above it already read its status from the enum.
The docblock still said the string variant goes through a double and can round
the value, which its own docblock — and the code, since it delegates here —
contradict.
The non-null assertion holds only because the validation loop above throws for
any update id missing from the stored lines; a reader had to reconstruct that.
Until the server owned the snapshot, the capture screen reopened every line
with no factor id and the API wrote emission_factor_id from that same field, so
a line edited after a reload kept its provider and lost its identity. Those
lines came back with an empty Factor cell, and the next edit either let the
recommendation replace a deliberate choice or dropped the factor and the line's
emissions with it. Provider, applied value, rate unit and dimension values are
matched back to the catalog, and anything ambiguous stays empty rather than
guessing a vintage.
@mrivas00
mrivas00 force-pushed the feat/mati/emission-factor-server-authority branch from 96a9436 to 78370d4 Compare September 10, 2026 20:29
@mrivas00

Copy link
Copy Markdown
Collaborator Author

Rebased onto the refreshed base of the chain (the schema PR's tip a00092e) and pushed with a lease. The 25 commits replayed without conflicts and one new commit was added, so every SHA quoted in the threads above has moved. Current mapping of the review fixes:

  • key the factor selection off the catalog id — 05d37ed
  • clear the catalog snapshot on reset — f63d03a
  • name a catalog factor the line can no longer select — 72cb3e8
  • match the API's required-dimension rule — 9c4e267
  • report a missing applied rate unit as such — 219cf64
  • reject a factor selection that contradicts the input type — 7167ce0
  • show the applied source next to the applied year — 440f64c
  • one summary row per applied vintage — b5837ee, with 4581b31 and cd68e26
  • bound the conversion that becomes the applied factor — 00f6228
  • resolve catalog factors without per-line lookups — f32a3a6
  • year-mismatch warning agrees in number — 15f62b9
  • applied factor year in the Excel export — 6389e57
  • dimension status enum — 85f9504
  • stale note about the string conversion — 5476c0c
  • invariant behind the subcategory lookup — 382fbfc
  • recover the catalog factor of a line saved without its id — 78370d4

Every review thread is resolved. One condition to carry forward, from the thread on the sync helper: this PR must not be merged into main on its own — the UNCHANGED variant that stops a re-save from rewriting a historical snapshot lands in the next PR of the chain, so 628 merges behind 629, or neither does.

@mrivas00 mrivas00 closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: api apps/api (Fastify backend) area: packages shared packages/* area: web apps/web (React frontend) priority: medium Medium priority type: feature new user-facing capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant