Skip to content

docs(openspec): plan emission factors by reporting year - #626

Closed
mrivas00 wants to merge 33 commits into
mainfrom
docs/mati/emission-factor-year-plan
Closed

mrivas00 wants to merge 33 commits into
mainfrom
docs/mati/emission-factor-year-plan

Conversation

@mrivas00

@mrivas00 mrivas00 commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

What & why

  • The factor catalog encoded the reporting year inside the free-text source ("DEFRA 2025"), so factor identity, vintage selection and year-mismatch warnings could not be expressed at all.
  • This PR lands only the plan — proposal, design, capability specs, tasks, rollout and the migration verification script. No product code. Implementation follows in 3 chained PRs.
  • Reviewing this first is the cheap way to challenge the decisions below before reading ~5k lines of code.

Key changes & decisions — the ones that shape every later PR:

1. year = NULL means transversal, never "unknown"

specs/emission-factor-vintages/spec.md, factor-classification.md · c5b52b3, 69ba58c

  • Trigger — every existing factor had to be classified before the year column could exist.
  • Change — NULL is a positive claim ("confirmed applicable to every reporting year"); an unclassified factor is a preflight error, not an inferred null.
  • Why this way — inferring "transversal" from a name like IPCC or Kool, A. would silently mis-key the catalog, and a wrong null is unrecoverable once rows start colliding on the new unique index. Failing the migration is the only reversible option.

2. Factor identity keys on unit family, not exact unit

design.md, specs/emission-factor-vintages/spec.md · d1e9772, 5f325b9

  • Trigger — kg/kg and kg/ton are the same factor expressed differently, so an exact-unit key would let mathematically equal rows coexist forever.
  • Change — identity = (subcategory, normalized required dimensions, year, source, numerator magnitude, denominator magnitude).
  • Why this way — the magnitude pair is the coarsest key that still separates genuinely non-convertible families. Real wet/dry-style distinctions are modelled as dimensions instead of being smuggled in through the unit.

3. Mismatch is derived at read time, never stored

design.md, specs/emission-factor-year-selection/spec.md · ec8dfe0, 5f325b9

  • Trigger — the obvious modelling would add isFallback / appliedFactorYearMatchesInventory columns.
  • Change — rejected both; only the historical applied_factor_year snapshot is stored, and mismatch is computed against the current inventory year.
  • Why this way — the inventory year is editable, so a stored boolean goes stale on the next edit and invites a bulk re-resolution path that would rewrite the user's own factor choices and results.

4. Dimension normalization applies to the comparison, not to the data

design.md, tasks.md (2.4) · 5f325b9

  • Why this way — normalizing stored rows would erase a value a maintainer deliberately entered in a non-required slot. The migration compares normalized, writes nothing.

5. No bulk re-resolution after a year change

specs/emission-factor-year-selection/spec.md (8.5) · ec8dfe0

  • Why this way — re-dating an inventory (or duplicating one) must preserve every historical snapshot. The warning is informational and never blocks save, navigation or submission.

Deliberate deviations

  • tasks.md ships with every item already checked [x]. The work is done — it lives on the three chained PRs below, not on this one. Reading this PR as "plan complete, code pending" is correct; reading a checked box as "merged" is not.
  • verify-migration.sh is added here but not executed by this PR — it is exercised by the schema PR (feat/mati/emission-factor-year-schema).

Test plan

  • pnpm format:check && pnpm lint && pnpm type-check — green (no code touched; run to prove the docs don't break the gates).
  • Read proposal.md → design.md → the two capability specs; challenge decisions 1-5 here rather than on the code PRs.

Chain — merge in order:

  1. docs(openspec): plan emission factors by reporting year #626 — openspec plan — decisions, specs, rollout (docs only) ← this PR
  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
  4. feat(carbonInventory): keep a line's stored factor when the request says unchanged #629 — UNCHANGED variant + batched catalog lookups

Out of scope

  • All product code. This is the plan only.

@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.

Review: emission factor year plan

Docs-only change: 8 new files, 2406 insertions, no product code. The only executable artifact is verify-migration.sh (mode 755, 125 lines), and that is where most of the correctness risk sits.

16 findings — 5 alta, 8 media, 3 baja. All of them are posted inline; every file in this change is newly added, so every referenced line is part of the diff and nothing needed to be grouped here.

The three that matter most

  1. design.md line 163 — consolidation orphans saved capture lines. Step 2 retires the duplicate kg/kg representations by soft-deleting them, but nothing re-points carbon_inventory_line_factor.emission_factor_id at the canonical survivor. The methodology payload filters to ACTIVE, so task 7.7 ("restore the selected canonical factor by ID") cannot resolve the saved choice, and the rollout spot-check passes anyway because it only checks snapshot columns.
  2. verify-migration.sh line 60 — the atomicity check does not check atomicity. Plain psql -f without --single-transaction does not reproduce how Prisma applies a migration, so the property rollout.md reports as verified is never exercised.
  3. analysis.html line 1318 — the artifact contradicts its own change directory. It presents the classification gate and the whole implementation as pending, while tasks.md marks those tasks done and factor-classification.md records them as approved on 2026-09-01.

Verified as correct, for the record

NULLS NOT DISTINCT is safe here: the repo pins postgres:18.4-alpine everywhere, well above the PG 15 requirement. The counts in factor-classification.md reconcile exactly against the seed (base DEFRA 195 / IPCC 45 / Kool, A. 3 / EcoAct 3 = 246; testing 245), as do all ten family counts and the 16 mass/mass collision groups. Its cross-change claim about the energy-unit classification holds — the two kg/GJ factors are Biodiesel and Bioetanol, disjoint from the ten kg/kWh fuels in stationary combustion. All nine in-page anchors in analysis.html resolve, and the format:check glob excludes .html, so CI stays green.

Verdict

The plan itself is well built, but it should not merge as is: the planned migration loses the factor selection on already-captured lines, and the script meant to catch that asserts nothing and prints success unconditionally. Worth resolving the five alta findings before the schema and server-authority PRs build on this; the media and baja ones can ride along in the same pass.

Comment thread openspec/changes/add-emission-factor-year/design.md Outdated
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh Outdated
Comment thread openspec/changes/add-emission-factor-year/design.md Outdated
Comment thread openspec/changes/add-emission-factor-year/analysis.html Outdated
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh Outdated
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh Outdated
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh Outdated
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh
Comment thread openspec/changes/add-emission-factor-year/rollout.md
Comment thread openspec/changes/add-emission-factor-year/tasks.md
Comment thread openspec/changes/add-emission-factor-year/factor-classification.md Outdated
Comment thread openspec/changes/add-emission-factor-year/design.md Outdated
Comment thread openspec/changes/add-emission-factor-year/rollout.md Outdated
Comment thread openspec/changes/add-emission-factor-year/specs/emission-factor-vintages/spec.md Outdated
Comment thread openspec/changes/add-emission-factor-year/analysis.html Outdated
Comment thread openspec/changes/add-emission-factor-year/tasks.md
Comment thread openspec/changes/add-emission-factor-year/verify-migration.sh Outdated
Comment thread openspec/changes/add-emission-factor-year/design.md
Comment thread openspec/changes/add-emission-factor-year/tasks.md Outdated
@mrivas00

mrivas00 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Triage of the duplicate review round

A second /code-review round ran against this PR by mistake at 21:08Z, after the round at 20:03Z, and posted 15 more inline comments. I went through all 15 against the first round and against the repo. Result: 4 resolved as pure duplicates, 11 left open.

Resolved — same defect, same fix, already tracked in the 20:03Z thread on the same line: the orphaned carbon_inventory_line_factor.emission_factor_id, the psql -f transaction divergence, the fixtures that inject nothing, and the raw DELETED count. Each carries a reply pointing at the surviving thread.

Left open, genuinely new (7). These are not in the first round at all:

  • tasks.md:14 — section 2 has no step that retires the same-family duplicates, so CREATE UNIQUE INDEX fails on real data. The first round assumed the retirement existed and only asked that it re-point its references.
  • factor-classification.md:29 — the audit is stale against origin/main. Reproduced exactly: this branch's seed gives 246 factors / IPCC 45 / 16 collision groups, origin/main gives 284 / IPCC 83 / 35, all mass/mass. My earlier summary called these counts verified; that was against this branch's own seed only, and this finding is right.
  • design.md:164 — Migration Plan step 3 says "normalize non-required dimension slots", which contradicts Decision 3, task 2.4 and emission-factor-vintages/spec.md:46, all of which forbid normalizing on persistence.
  • specs/emission-factor-year-selection/spec.md:100 — the CATALOG validation ties the applied rate unit to the factor's magnitude family but never to the line's own measurement_unit_id, so a quantity in tons against a kg/kg applied unit is off by 1000 and gets persisted as a server-derived snapshot.
  • specs/emission-factor-vintages/spec.md:48 — "The application and a partial database unique index SHALL enforce the same key" contradicts design.md, which deliberately documents the application check as stricter than the index.
  • rollout.md:51 — both commands diverge from the contract documented in docker-compose.prod.yml: no --env-file, and up migrate instead of the documented --profile migrate run --rm migrate. Without the env file MIGRATION_DATABASE_URL is empty and the one-shot fails fast.
  • tasks.md:21 — wrong path. The methodology seed schema is tools/seed/src/scripts/shared.ts; seedMethodologyData/ holds seedEmissionFactors.ts but no shared.ts.

Left open, partly overlapping (4). Each has a reply marking which part is already tracked and which part is not:

  • analysis.html:1321 — staleness and the rateUnitId naming are duplicates; the openspec status / all_done archivability question is new.
  • verify-migration.sh:51 — the three listed defects are duplicates; HUP missing from the trap is new and correct.
  • design.md:102 — the finding duplicates, but the proposed alternative (make InputType the discriminator instead of adding one) is a real design call worth making explicitly.
  • tasks.md:89 — the premise is wrong: openspec validate add-emission-factor-year --strict does pass (CLI 1.3.1, exit 0). The observation is right: this is the only active change without a tracked .openspec.yaml.

No code, branches or pushes were touched by either round.

@mrivas00

Copy link
Copy Markdown
Collaborator Author

Conversation cleaned up

Follow-up to the triage note above, which is now out of date. Both review rounds have been worked through.

31 threads → 4 open. The four pure duplicates from the second round were deleted rather than resolved, so there is one thread per issue.

Fixed locally (23 threads). 22 commits on docs/mati/emission-factor-year-plan, not pushed — say the word and I will. Each thread carries a reply naming its commit. The substantive ones from the second round:

  • the missing duplicate-retirement step, without which CREATE UNIQUE INDEX runs with every collision group still present — fe87b17;
  • the CATALOG path accepting any unit in the right magnitude family, so kg/kg could be applied to a quantity captured in tons — c861b64;
  • Migration Plan step 3 normalizing stored dimension slots against Decision 3 and task 2.4 — fdb415c;
  • the production deploy commands diverging from docker-compose.prod.yml — 42be23e;
  • the spec claiming the index and the application enforce the same key — ef2ebca;
  • the wrong seed schema path — 404194c;
  • HUP missing from the cleanup trap, and the absent .openspec.yaml — 7d0dad6.

openspec validate --strict, format:check, lint and type-check all pass after the edits. verify-migration.sh still has not been executed against a real database — that needs a Postgres with the pre-change catalog and the generated migration, neither of which exists on this branch.

Open, waiting on you (4). All four are decisions rather than defects:

  1. factor-classification.md:29 — the audit is stale. This branch gives 246 factors / IPCC 45 / 16 collision groups; origin/main gives 284 / IPCC 83 / 35. Re-running it is methodology-owner work, and the approved map needs re-approval against the merge-base the schema PR actually branches from. Task 2.10 is deliberately worded without a count so it does not bake in the stale 16.
  2. design.md:102 — keep inputType and constrain the pairing (what I implemented), or make InputType the discriminator and drop the parallel taxonomy. The two axes are not the same split: SIMPLIFIED vs EXPERT is a capture mode a CATALOG/CUSTOM split cannot express.
  3. analysis.html:1321 — what tasks.md checkboxes track: this PR, or the whole chain. With every box [x], openspec status reports the change complete while the implementation PRs are unmerged.
  4. verify-migration.sh:1 — move the script to infra/ so the existing shellcheck job covers it, or widen that job to openspec/changes/**/*.sh.

@mrivas00

Copy link
Copy Markdown
Collaborator Author

Pushed

The 22 review fixes are now on the branch — a7c9a32..7d0dad6, fast-forward, no history rewritten and no sibling branch touched. The comment above said "not pushed"; that is no longer true.

Every fixed thread carries a reply with what changed and its commit SHA, and all 23 are resolved. Local gates before pushing: pnpm format (no changes), pnpm lint 13/13, pnpm type-check 13/13, openspec validate add-emission-factor-year --strict, bash -n on the script. No test suite applies — the change touches only openspec/**. On the pushed tip every completed check passes, including Secret Scan; CodeQL was still queued when I looked. Worth knowing that CI Lint and Type Check finished in seconds here because the changes filter short-circuits them for a docs-only PR, so the full local runs are the real signal.

Still open, waiting on a decision (4). Nothing else remains.

  1. factor-classification.md:29 — the audit is stale: this branch gives 246 factors / IPCC 45 / 16 collision groups, origin/main gives 284 / 83 / 35. Re-running it and re-approving the map against the merge-base the schema PR branches from is methodology-owner work. Task 2.10 is worded without a count so it does not bake in the stale 16.
  2. design.md Decision 6 — keep inputType and constrain the pairing, which is what 294a6a2 implements, or make InputType the discriminator and drop the parallel taxonomy.
  3. analysis.html — what the tasks.md checkboxes track: this PR or the whole chain. With every box [x], openspec status reports the change complete while the implementation PRs are unmerged. This thread now shows as outdated, since the surrounding section was rewritten in a12ca91.
  4. verify-migration.sh:1 — move the script to infra/ so the existing shellcheck job covers it, or widen that job to openspec/changes/**/*.sh. shellcheck has still never run over this file.

A footprint measured for 2022 is offered the newest factor in the
catalog: the year is not part of a factor's identity, it lives inside
the free-text source ("DEFRA 2025"), and both the uniqueness check and
the one-source-per-subcategory rule reject a second vintage outright.

The proposal makes the year a column of its own (null = transversal,
serves any measurement year), moves it into the uniqueness key, and
turns capture into an explicit vintage selector: exact year, else
transversal, else the most recent earlier year, else the closest later
one with a notice. The applied year and the fallback flag are recorded
on the line so a verifier can reproduce the number.

Definition closed with the Product Owner over 16 questions.
The design records why the year becomes a column on emission_factor
rather than a vintage table or one methodology version per year, why a
null year encodes "transversal", and why resolution stays on the client
that owns the selector. Two decisions are worth flagging on review: the
partial unique index gains the year and NULLS NOT DISTINCT, because
three of its four columns are nullable and it therefore never prevented
the duplicates it was meant to; and validateSourceConsistency is scoped
per year instead of dropped.

The specs cover the catalog side (the year as an attribute, coexisting
vintages, one source per subcategory and year, the year travelling with
every published representation) and the capture side (the four-step
preselection, the override, the recorded year, and the offer to update
when a footprint is re-dated).

Open question carried into the tasks: which factors are genuinely
transversal needs the methodology team before any vintage is loaded.
Every existing factor has to be classified as dated or transversal before the
migration can split provider from reporting year, and a missing year suffix is
not evidence of either. This is the reviewed map the migration validates
against: DEFRA 2025 and EcoAct 2020 by their recognized suffix, IPCC and
Kool, A. confirmed transversal by the methodology owner rather than inferred
from their names.

Also records the unit-family audit behind the consolidation: 16 collision
groups, all of them one IPCC industrial-process factor stored twice as kg/ton
and kg/kg, all mathematically equivalent. No group needed methodology review,
and re-running the audit with kWh reclassified as energy (the pending
fix-energy-unit-classification change) yields the same 16, so the two changes
do not conflict on seed data.
…rollout

Decision 3 said non-required dimension slots are normalized to null "before
persistence". Implementing that discarded a value a maintainer had entered in an
optional slot, and would have had the production migration erase it from existing
factors. The previous duplicate check already normalized for comparison only
while persisting whatever was set, so this clause was the one that changed
behaviour rather than preserving it.

Normalization is now scoped to the uniqueness comparison in the design, the
migration task and the vintages spec, with a scenario pinning that an optional
dimension value survives a save without creating a separate identity. The
accepted consequence — the application check being marginally stricter than the
index, which compares raw columns — is written down where the decision is.

Adds a rollout note, because the ordering is not obvious from the code: the
migrate profile runs before api and web come up together, and the classification
gate has to be re-checked against production before either. It also records what
to watch before loading more vintages — methodology payload size, and the fact
that a second provider for one activity turns a silent default into an explicit
choice for every organization capturing it.

Adds verify-migration.sh for the checks vitest structurally cannot make: the
harness migrates once against a clean database, so it never exercises a preflight
whose whole job is to stop a migration. The script stages throwaway databases
from a pre-change one, injects an unclassified source and a same-family value
conflict, and asserts each aborts.
stage() moves the migration directory into a temp dir so `migrate deploy` stops
at the previous commit, and moves it back two lines later. With `set -euo
pipefail` and no trap, anything that failed in between — an unreachable
Postgres, a wrong DATABASE_URL, a Ctrl-C — ended the script before the restore,
deleting the migration from the checkout.

The trap covers the failure, the interrupt and the normal exit, and is cleared
once the move back has happened.
Retiring a duplicate same-family representation soft-deletes it, and the
methodology payload returns only ACTIVE factors, so every
carbon_inventory_line_factor still pointing at the retired row loses its
saved selection while its value/source/unit snapshots stay intact. The
rollout spot-check only compared those snapshots, so it passed.

Make the re-point part of the consolidation step, read applied_factor_year
from the surviving row, and have the spot-check assert that no saved line
references a DELETED factor.
Decision 4 removes validateSourceConsistency, which is what currently
rejects a second spelling of a provider inside a subcategory. After that,
source is maintainer-entered free text that carries identity, so "DEFRA ",
"Defra" and "DEFRA" become three rows that are physically identical, tie in
the same rank, and leave capture asking the organization to choose between
options that look the same on screen.

Normalize on write (trim, collapse inner whitespace, reject empty) and fold
case in the comparison, keying the partial unique index on lower(source).
The design and tasks phrased the tie rule as "factors from several sources"
while the capability spec phrased it as "exactly one candidate", leaving a
winning rank with two factors from one provider undefined. That case is
reachable: Decision 3 keeps a value entered in a non-required dimension slot
and task 2.4 forbids the migration from erasing it, so migrated data can
carry exactly that pair — and the fallthrough would pick by array order or
ID, which the same paragraph forbids.

Use the candidate-count wording everywhere and add the scenario.
The CATALOG | CUSTOM | DIRECT discriminator overlaps the already-persisted
inputType enum, and nothing said whether inputType is kept, derived or
dropped. Both are client-authored, so an implementer could leave two
discriminators free to contradict each other, and deriving one from the
other is not free either: SIMPLIFIED versus EXPERT is a capture mode the
factor split does not carry.

Keep inputType as the capture mode, derive the factor path from the variant
alone, and reject contradictory pairings.
…ecking aborts

psql does not wrap a file in a transaction the way prisma migrate deploy
does, so every statement before the failing preflight auto-committed and the
check passed regardless of whether the migration was atomic — the very
property rollout.md reports as verified.

Apply with --single-transaction, assert the added columns are gone after an
abort, and stop treating a connection failure as a migration result.
The check printed three query results and then reported success
unconditionally, including for the query labelled "must be 0", so a run
against a catalog that differs from the reviewed one still exited 0 while
rollout.md told the operator the check asserts no duplicate business key
survives.

Compare each result, add the magnitude-pair and orphaned-line assertions,
and report the DELETED count as a delta against a baseline taken before the
migration, since a restored production copy already contains soft-deleted
factors. Staged databases are kept when a check fails.
Both fixtures were INSERT ... SELECT ... LIMIT 1 with no row-count check, so
a predicate that matched nothing inserted zero rows and exited 0: the defect
was never injected, the migration then ran cleanly, and expect_abort blamed
the migration. Check 2 is the likely one — it needs a subcategory named
exactly "Procesos industriales - Vidrio", which is per-deployment catalog
data, and it did not filter on ACTIVE.

Wrap both in a DO block that raises on ROW_COUNT <> 1, add the ACTIVE filter,
and set ON_ERROR_STOP for every psql_db call.
PGPASSWORD was the only connection variable without a default under set -u,
so on a host using .pgpass, trust or peer auth the script died with an
unbound-variable error inside stage() — after the migration directory had
already been moved aside — even though every direct psql call worked.

It was also interpolated raw into the Prisma DATABASE_URL, so a password
containing @ / : # or % parsed into a different host or database. Percent-
encode user and password, and correct the documented PGPORT default to 5432,
matching the packages/database compose that supplies the other defaults.
CREATE DATABASE ... TEMPLATE is the first statement in stage(), before the
trap is armed, and it fails while anything holds a connection to the
template — which the local dev API pool does against testdb by default.
stage() runs three times, so the template had to stay idle for the whole run
with nothing saying so.
The path hardcoded 20260901120000, a timestamp this change does not create:
task 2.3 leaves it as <timestamp>_add_emission_factor_year, stamped when
Prisma generates the migration. Any other value made stage() fail with a bare
"mv: cannot stat", and the already-armed trap then silently restored a path
that had never existed.
The trap existed to put the parked migration back, then swallowed both stderr
and the exit status with 2>/dev/null || true — so in exactly the case it
guarded, a failing restore, the operator was told nothing and walked away
with the migration gone from the working tree.

Install one cleanup trap that reports a failed restore with both paths and
exits non-zero, removes the temp directory each stage created, and names the
staged databases it kept when the run ends badly.
The payload block called it rateUnitId while design.md, both capability specs
and task 3.4 call it rateMeasurementUnitId — the long form used everywhere
else in the repo. Copying from this artifact would land the two halves of the
discriminated union with different field names, failing Zod validation at
runtime rather than at type-check.
The table covered ties between providers and between transversal providers
but not two candidates from one provider, which is the case the design and
spec disagreed about.
The page listed tasks 1.1-1.4 and all of sections 2-11 as pending while
tasks.md marks them done and factor-classification.md records the
classification as approved by the methodology owner on 2026-09-01, and it
claimed 4/4 artefacts for a directory that now ships nine. A reader would
conclude the classification gate is still open and redo it.

Report what is actually closed, point at tasks.md for per-task progress
rather than restating it, and list only the deployment steps that remain.
The step described the third check as printing two numbers and asserting one
thing. It now reports the retired count as a delta and asserts three
conditions, and it needs the generated migration to be present.
The methodology seed schema lives in tools/seed/src/scripts/shared.ts.
tools/seed/src/scripts/seedMethodologyData/ holds seedEmissionFactors.ts but
no shared.ts, so the path in task 3.2 pointed at a file that does not exist.
The Migration Plan describes consolidating same-family duplicates, but
section 2 of tasks.md never turns it into a step: 2.3 splits provider/year,
2.4 backfills magnitudes, 2.5 creates the unique index, 2.6 adds
applied_factor_year. Nothing retires a collision, so 2.5 would run with every
audited group still present and fail on real data.
…ions

Step 3 told the migration to normalize non-required dimension slots, which
contradicts Decision 3, task 2.4 and emission-factor-vintages spec — all of
which state that normalization applies to the comparison only and that a
value entered in a non-required slot is stored and returned unchanged.
The requirement said both enforce the same key, while design.md Decision 3
documents the opposite on purpose: the index compares raw dimension columns
and the application normalizes non-required slots, so the application rejects
a little more than the constraint does.
The CATALOG validation list checked only that the applied rate unit shares
the factor's magnitude family. kg/kg and kg/ton share mass/mass, so a line
whose quantity was captured in tons passes that check against a kg/kg applied
unit and the server persists a result wrong by three orders of magnitude —
under the snapshot the design calls server-authoritative.
Both commands diverged from the contract in docker-compose.prod.yml: neither
passed --env-file, so MIGRATION_DATABASE_URL resolves empty through its soft
default and the migrate one-shot fails at validate:version; and the one-shot
is invoked with --profile migrate run --rm migrate, not up.
…data

The cleanup trap did not catch HUP, so an SSH drop during the multi-minute
migrate deploy left the migration directory inside a mktemp temp dir and gone
from the working tree.

Also adds the .openspec.yaml every other change directory carries. Strict
validation already passed without it, but this was the only active change
missing the file.
@mrivas00
mrivas00 force-pushed the docs/mati/emission-factor-year-plan branch from 7d0dad6 to 6bf4192 Compare September 10, 2026 11:42
@mrivas00

Copy link
Copy Markdown
Collaborator Author

Rebased onto main

New tip 6bf4192 (was 7d0dad6). Rebased 29 commits onto origin/main at 9ccbf3a and force-pushed with --force-with-lease pinned to the previous remote SHA. No conflicts — this branch only touches openspec/**, and the change directory is byte-identical to its pre-rebase state. Only this branch was pushed.

CI on the new tip: 22/22 pass. Local gates before pushing: pnpm format (no changes), pnpm lint 13/13, pnpm type-check 13/13, openspec validate --strict, bash -n.

The branch now carries the 38 new IPCC factors from main (Ferroaleaciones 28, Cal 6, Aluminio 4), so PR 627 can rebase on top and give them an explicit year.

One consequence worth flagging: this turns the open factor-classification.md thread from a drift against main into a contradiction inside this PR — the branch seed now says 284 factors / IPCC 83 / 35 collision groups while the approved classification file still says 246 / 45 / 16. Details in that thread. The other three open threads are unchanged.

…logue

main added three industrial-process subcategories with factors —
Ferroaleaciones y otros metales, Cal and Aluminio, 38 factors, all IPCC — so
every count in this file was measured against a catalogue that no longer
exists: 246 factors, IPCC 45 and 16 collision groups, versus 284, 83 and 35
today.

Recomputed all of them. The classification map itself is unchanged, since the
four source values are the same and every added factor is IPCC, already
classified transversal. The conclusions hold too: the 35 groups are still all
mass/mass, still all kg/ton against kg/kg, and zero disagree after conversion,
so none needs methodology review. Only the scope grew, from four subcategories
to seven.
…gories

The consolidation now covers 35 collision groups across seven
industrial-process subcategories, not 16 across four: main added
Ferroaleaciones y otros metales, Cal and Aluminio, which contribute the other
19 kg/ton against kg/kg twin pairs.

The migration detects collisions from the data rather than from a list of
subcategory names, so the step itself needed no change — only the plan was
describing a narrower scope than the code. Marks 2.8, 2.10 and 6.7 done: the
schema PR re-points carbon_inventory_line_factor.emission_factor_id and
retires the duplicates, and the server-authority PR rejects a factor variant
that contradicts inputType.
tasks.md had every box from 1.1 to 11.2 checked while nothing was merged,
which read as a claim about this PR and made openspec status report the change
complete. The boxes track the change across its implementation branches, not
this planning PR, so say that where the boxes are and repeat it in the analysis
page.

Also refreshes the audit numbers on that page to match the re-audited
classification: 284 and 283 factors, and 35 collision groups across seven
industrial subcategories.
verify-migration.sh is a 125-line operational script an operator runs against
a restored copy of production, and no gate covered it: format:check excludes
.sh, the docs-link job does not reach openspec/**, and the shellcheck glob was
infra-only. That was the last ungated script location in the repo.

Decided to keep it beside the change it documents — it is meaningless once the
migration ships — and to widen the gate instead of moving the file. The glob
now covers openspec/changes/**/*.sh, with nullglob so a change that ships no
script keeps the job green. The script is clean at warning severity today, and
at style severity too.
@mrivas00

Copy link
Copy Markdown
Collaborator Author

All threads closed

Tip ad0161e, CI 22/22 pass, mergeable. Four new commits on top of the rebase, pushed fast-forward — no history rewritten, so no force was needed. All 27 review threads are now resolved, 0 open.

  • 410dbef — re-ran the unit-family audit against the current catalogue and rewrote every number: 284 factors in base, 283 in testing, IPCC 83, mass/mass 130 → 168, and 35 collision groups instead of 16. No conclusion moved: the classification map is unchanged, all 35 groups are still kg/ton against kg/kg, zero disagree after conversion, all seven subcategories allow g/kg/ton, and the cross-change note still holds under the merged energy family.
  • acfb56d — the retirement step now describes seven industrial-process subcategories rather than four. The migration finds collisions from the data, not from a list of names, so the code already covered the wider scope; only the plan lagged. Marks 2.8, 2.10 and 6.7 done against what the chain actually implements.
  • a15a2f9 — states what a checked box means: the boxes track the change across its implementation branches, not this PR, and nothing is checked on the strength of the plan alone. The analysis page defers to tasks.md and its audit figures were corrected to match.
  • ad0161e — the migration-verification script keeps its place beside the change and the shellcheck job was widened to openspec/changes/**/*.sh instead. It is dead weight in infra/ once the migration ships, and the objection was about coverage, not location. The job linted 9 scripts on this tip and passed.

On the discriminator thread: no design change was made or needed. The server-authority PR already keeps inputType as a persisted capture mode with assertFactorSelectionMatchesInputType rejecting a contradictory pairing, which is exactly what Decision 6 documents. Making InputType the discriminator would be a real design change and would lose the SIMPLIFIED versus EXPERT distinction, so it was left alone.

One caveat for anyone reading older replies: the rebase onto main rewrote this branch, so SHAs cited before that point no longer exist on it. The four commits above are current.

four `source` values are the same and every new factor is `IPCC`, already
classified transversal. What changed is the size of the sets it applies to, and
every count in this file has been recomputed. The conclusions all still hold;
the expanded consolidation set is the part worth the methodology owner's

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.

Open on purpose: the expanded consolidation set has not been re-approved.

This file is signed "Approved by the methodology owner on 2026-09-01", and that approval covered 16 collision groups across four industrial-process subcategories. The catalogue on main has since grown, so the set the migration will actually consolidate is 35 groups across seven — Ferroaleaciones y otros metales, Vidrio, Acero, Cal, Cinc, Aluminio and Cemento.

What I did in 410dbef is mechanical only: I recomputed every number from the seed and re-ran the equivalence check. Zero of the 35 groups disagree after conversion, the classification map is unchanged because all 38 added factors are IPCC and IPCC was already classified transversal, and the canonical kg/ton choice still holds because all seven subcategories allow g, kg and ton. So nothing contradicts the original decision.

But recomputing is not approving. The 19 groups from the three new subcategories were never reviewed by a person, and the migration soft-deletes a catalog row for each of them. That is a methodology sign-off, not a code review, so I am leaving this thread open rather than resolving it on the strength of my own arithmetic. Rollout step 1 is the backstop — it re-checks the production catalogue against this map before the migration runs — but it validates sources, not consolidation groups.

Resolve this once the methodology owner has confirmed the 35-group set, and update the approval line with the new date.

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.

Making this fully explicit, since it is the one thread that stays open and it is a sign-off, not a code change.

What is missing. Nothing in the file or the code. The numbers were already refreshed in 410dbef and the migration handles the wider set on its own, because it detects collisions from the data rather than from a list of subcategory names. What is missing is a person re-approving the consolidation set. The approval line still reads 2026-09-01, and that approval was given over a catalogue that no longer exists.

What changed against what the approval covered. The file today documents the current catalogue; the 2026-09-01 sign-off covered the left-hand column:

Approved 2026-09-01 Documented today
Factors, base / testing 246 / 245 284 / 283
IPCC factors 45 83
mass/mass family 130 168
Collision groups 16 35
Subcategories affected 4 7

The four new industrial-process subcategories with collisions are Ferroaleaciones y otros metales (14 groups), Cal (3), Aluminio (2) — 19 groups the owner has never seen — on top of Vidrio (8), Acero (4), Cinc (3) and Cemento (1), which are the originally reviewed 16.

What does not change, and so is not part of the ask: the source-to-vintage map is identical, because all 38 added factors are IPCC and IPCC was already classified transversal; all 35 groups are still kg/ton against kg/kg inside mass/mass; zero groups disagree after unit conversion, so none is a methodology question; all seven subcategories allow g, kg and ton, so kg/ton remains the canonical unit; and the fix-energy-unit-classification cross-change note still holds, yielding the same 35 groups with no new collisions.

Why it needs the owner and not me. The migration soft-deletes one catalog row per group. For 19 of them that deletion would rest on my arithmetic alone. Recomputing is not approving, and rollout step 1 does not cover it — it re-checks production source values against the map, not the consolidation groups.

Waiting on the methodology owner. Deliberately left open and not resolved. When the 35-group set is confirmed, update the approval line with the new date and close this thread.

@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: docs docs/ and root docs priority: medium Medium priority type: docs documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant