Declared-unit seam — design spec¶
Status: SPEC (not implemented). Written 2026-10-01 against origin/main @ 91c6009.
Tracks: AR-46 (unit-base mismatch), AR-53 (RAW_EARTH/EARTH), AR-59 (glass), AR-63 (seed wiring), AR-05 (tiles), AR-51 (PE film).
Scope of the PR this spec defines: the mechanism only. It wires no new factor and changes no reported number.
0. What the code does today (read pass, cited)¶
0.1 A1-A3 unit handling¶
| Fact | Where |
|---|---|
The formula is E = weight × factor, and the factor is always kg CO₂-eq per kg. |
backend/app/domains/lca/a1_a3.py:6-7, :534 (weight_kg * spec.factor) |
_Factor has no unit field. The per-kg basis is implicit in every one of the 18 records. |
a1_a3.py:73-109 |
compute_a1_a3(weights_kg: Mapping[MaterialCategory, float]) takes bare floats. It does no unit check because it has no unit to check. |
a1_a3.py:566-591 |
A unit-aware entry point already exists: compute_a1_a3_submitted(Sequence[(category, Quantity)]). It accepts kg for all 18 records. For GLASS, PLASTIC_FILM and CERAMIC_TILE it also accepts m2, and returns those rows BLOCKED with DECLARED_UNIT_PENDING_REASON. Any other unit raises ValueError. |
a1_a3.py:616-701 |
Units are validated in two places: (1) project_compute._validate_materials returns a 422 unit_not_accepted from accepted_units(); (2) compute_a1_a3_submitted repeats the check as a ValueError backstop. |
backend/app/services/project_compute.py:95-105; a1_a3.py:684-688 |
DECLARED_PER_M2 is tied to the seed files' declared_unit by a unit test. |
backend/tests/unit/test_project_compute.py:114-123 |
Correction to the brief. The engine is not purely per-kg today. A placeholder for the seam already ships (AR-72, PR #59): the request contract accepts m² for the three per-m² categories and answers BLOCKED. This spec replaces the inside of that placeholder and keeps its external contract unchanged.
0.2 Seed EPDs and their declared units¶
| File | declared_unit |
Wired into A1-A3? | Conversion data (grammage / thickness) |
|---|---|---|---|
cement.json |
1 t | yes (seed_epd_registry.py:48) |
n/a (mass) |
steel_rebar.json |
1 kg | yes | n/a |
crushed_rock.json |
1 kg | yes | n/a |
sand.json |
1 kg | yes | n/a |
steel_profile.json |
1 kg | no | n/a |
glass.json |
1 m2 | no (AR-59) | prose only: "1 m² at a layer thickness of 1 mm … grammage 2.5 kg/m² … density 2500 kg/m³" (product_description) |
ceramic_tile.json |
1 m2 | no (AR-05) | prose only: "grammage of 20 kg/m²" |
plastic_film.json |
1 m2 | no (AR-51) | prose only: "grammage of 1.2 kg/m²" |
FACTOR_TABLE(a1_a3.py:467) is a public alias of_FACTORS(a1_a3.py:172-459). Four rows come from_from_seed(a1_a3.py:112-154). The other fourteen are inline per-kg literals, and that includes GLASS (9.71 × (1/0.006) × (1/2200),a1_a3.py:362) and PLASTIC_FILM (2.770,a1_a3.py:294). CERAMIC_TILE hasfactor=Noneand is BLOCKED (a1_a3.py:442).- The registry normalises mass units to kg at load:
declared_gwp / (qty × {kg:1, t:1000}[unit])(seed_epd_registry.py:57,:160). Any non-mass unit is rejected (:151-158). This is why the three per-m² files cannot be wired at all today. - Stale docstring:
seed_epd_registry.py:15-16lists "steel profile" among the per-m² records.steel_profile.jsondeclares 1 kg. AR-63's register row repeats the same error. Fix the docstring in the PR (§7, item 9). - Hazard for the seam:
glass.json'sdeclared_unitis{qty: 1, unit: "m2"}, but the dataset's real basis is m² at 1 mm. Read naïvely as "per m²", it would understate 6 mm glazing by 6×. Nothing structured in the file says so.
0.3 The provenance gate¶
- Runtime half:
seed_epd_gate.validate_seed_epd(backend/app/services/seed_epd_gate.py:51-110). It checks: not$test_only,ext.chwr.proxy_provenancepresent and parsing asProxyProvenance(cascade step, 5 Weidema axes 1–5, justification ≥ 280 chars, cross-field rules), approved programme operator, supported rubric version, and the shape ofsource_citation. - CI half:
backend/tests/data/test_seed_epd_integrity.py:90-165checks the same rules plus live URL/DOI resolution. - Neither half checks
declared_unit. The only check is the registry's mass-only loader (seed_epd_registry.py:140-158), and it runs only for the four files inCATEGORY_SEED_FILES. A seed file with nodeclared_unit, or withunit: "piece", passes the gate and CI today.ProxyProvenancehas no unit field (grep declared\|unit schemas/proxy_provenance.pyfinds nothing).
0.4 RAW_EARTH (m³) vs EARTH (kg), AR-53¶
quantitiesemits earth as(MaterialCategory.RAW_EARTH, acc.earth_m3, UnitEnum.M3)(backend/app/domains/quantities/__init__.py:600).- A1-A3 keys earth as
MaterialCategory.EARTHwithfactor=None, BLOCKED under the decision #26 hard block (a1_a3.py:208-229). The KS mass isEARTH: 1_494_888.525kg (a1_a3.py:480). - Nothing maps one key to the other.
lci_materialsmarksraw_earthasselectable: falseand points users toearth(backend/app/services/lci_materials.py:37-45). - No citable earth density exists. The 1 800 kg/m³ implied by
LCI Finalis back-solved and inadmissible (AR-47, AR-22). - Consequence: an m³→kg conversion for earth lands on a BLOCKED factor whatever happens. The seam has to state the rule, but it cannot produce an earth number.
0.5 The KS paths, and a correction to the brief¶
There are two Keur Songho paths. _KS_QUANTITIES is not the A1-A3 path:
| Path | Input | Units | Golden / parity |
|---|---|---|---|
Quantities: _KS_QUANTITIES → _run_keur_songho → _accum_to_result |
BoQ D-column line quantities (m², m³, u … per line item) | Mixed output: m³ (rock, gravel, sand, raw earth, water, steel sheet/profile, glass), kg (cement, rebar, bitumen), m² (PE film, tiles), L (oils, paints, pesticide) | quantities/__init__.py:448-502, :590-612; tests/regression/test_quantities_keur_songho.py; tests/golden/quantities_keur_songho.json |
A1-A3 / A4: _KEUR_SONGHO_WEIGHTS_KG → compute_a1_a3 |
LCI Final column F masses |
kg for all 18 | a1_a3.py:476-500, :594-603; tests/regression/test_a1_a3_keur_songho.py; tests/golden/a1_a3_keur_songho.json; tests/golden/a4_keur_songho.json |
The two paths are not connected (AR-46). A1-A3 never reads _KS_QUANTITIES. I read the brief's "_KS_QUANTITIES path + its goldens" as "the KS reference paths". §5 therefore makes the invariant cover both paths. That is a stronger condition than the brief asked for, not a different one.
1. Declared-unit model¶
Two terms, kept separate on purpose:
- Declared unit: what the source EPD declares (
declared_unit.{qty, unit}, openEPD-native, top level of the seed JSON). This is audit data and is never rewritten. - Factor basis: the unit that the stored
factoris per. It is set once, at load: - mass declared units (
kg,t) → normalised to basiskg. This uses the existing arithmetic, unchanged (seed_epd_registry.py:160). m2,m3→ basis = the declared unit, stored natively asdeclared_gwp / qty. No grammage or density is applied at load.
Admitted vocabulary (DECLARED_UNIT_VOCAB): kg, t, m2, m3. These map onto the existing UnitEnum.KG/T/M2/M3 (backend/app/schemas/units.py:15-18). Anything else (piece, m, L) is rejected at the gate. A new vocabulary member needs its own spec.
1.1 Basis-conversion block (non-mass declared units only)¶
A per-m² or per-m³ record may carry a structured block. It is placed in the openEPD extension, beside proxy_provenance, as CLAUDE.md requires for extensions:
"ext": { "chwr": {
"proxy_provenance": { ... },
"declared_unit_basis": {
"kg_per_declared_unit": 20.0, // grammage (m2) or density (m3), kg per declared unit
"reference_thickness_m": null, // set ONLY when the factor scales with thickness (glass: 0.001)
"source_citation": "https://www.oekobaudat.de/...", // must be the EPD's own dataset URL/DOI
"source_locator": "flowProperties: grammage 20 kg/m²" // where in the dataset the figure sits
}
}}
- Every value in the block must be read from the source EPD itself, and
source_citationmust equalproxy_provenance.source_citation. A value copied from the file's own prose does not qualify. It must be re-read from the cited dataset, because the "never invent EPD values" rule covers these figures too. reference_thickness_mset → the factor is thickness-scalable: the declared basis is "m² at t_ref". Unset → the area unit is product-defined: "m² of this product as made".- Pydantic model
DeclaredUnitBasis, inbackend/app/schemas/proxy_provenance.py, next toProxyProvenance.
1.2 Where each check lives¶
| Check | Runtime gate (validate_seed_epd) |
CI gate (integrity test) | Registry load (wired files only) |
|---|---|---|---|
declared_unit present; qty a positive finite number; unit ∈ vocab |
new, reject | new, reject | already (mass only) → generalised |
If declared_unit_basis is present: it parses, and its citation equals source_citation |
new, reject | new, reject | n/a |
Non-mass record wired into CATEGORY_SEED_FILES without declared_unit_basis |
n/a | n/a | reject (as today, with a new message) |
The gate requires a declared unit on every file. It does not require the basis block on every file. That requirement applies at wiring time. As a result the PR does not touch backend/app/data/seed_epds/: all eight vendored files already carry a valid declared_unit (§0.2), so they pass the tightened gate unchanged. The three per-m² files get their basis blocks in the follow-up PRs that wire them (§8).
2. Registry change¶
2.1 SeedFactor (seed_epd_registry.py:62-80)¶
- Add
basis: UnitEnum(KG,M2orM3) andfactor: float(per basis). - Keep
factor_per_kgas a property that returnsfactorwhenbasis is KGand raisesValueErrorotherwise. Existing callers (_from_seed,a1_a3.py:135) keep working, and code that silently treats a per-m² factor as per-kg fails loudly. - Add
basis_conversion: DeclaredUnitBasis | None. _factor_per_kg→_factor_per_basis. Forkg/tthe expression stays literallydeclared_gwp / (float(qty) * _KG_PER_MASS_UNIT[unit]). That keeps the four seed floats bit-identical (§5).
2.2 _Factor / FACTOR_TABLE (a1_a3.py:73-109)¶
- Add
basis: UnitEnum = UnitEnum.KG. All fourteen inline literals are per-kg, so the default is correct for every row and the table needs no textual edits._from_seedsetsbasis=seed.basis. - Add
basis_conversion: DeclaredUnitBasis | None = None, passed through from the seed. __post_init__gains one rule:basis ∈ {KG, M2, M3}. Ifbasis is not KGandfactor is not None, thensource_epd_idmust be set, because an inline literal may not claim a non-kg basis.accepted_units(category)(a1_a3.py:627-636) is unchanged in this PR. It keeps returning(KG, M2)forDECLARED_PER_M2and(KG,)otherwise, so the request contract and the 422 behaviour stay as they are. Deriving it frombasisand the available conversions belongs to the first wiring PR.
3. Multiply logic¶
There is one function, _apply_factor(spec: _Factor, quantity: Quantity) -> MaterialA1A3Result. compute_a1_a3_submitted calls it for every row. compute_a1_a3 (the dict/kg path) is not routed through it (§5).
Resolution order for each row:
- Category alias: none in this PR (see 3c).
- Unit admissibility:
quantity.unit ∉ accepted_units(category)→ValueError(unchanged). The API catches this as a 422 first. - Basis match or conversion, per the table below. For every non-identity row (submitted unit ≠ factor basis, i.e. every row except a1) the unit/basis rule runs first, before any factor-is-None check. Its BLOCKED outcome (b1, b3, b4, c1) or
ValueError(c2, unruled) is final, whether or not the record has a factor. - Factor present? Applies only to rule a1 (same-unit) rows:
spec.factor is None→ BLOCKED row carrying the record's ownblocked_reason(unchanged behaviour;_material_resultalready does this). Rules a2, a3, b1–b3 multiply byspec.factoronly after their own parameter checks pass; if the factor is then None the row is BLOCKED with the record's own reason.
(Order corrected 2026-10-01 before implementation. The first draft ran the factor-is-None check before the basis rule, which would have sent an m² CERAMIC_TILE row — factor=None — to the tile's own reason instead of b4, and an m³ EARTH row to the factor gap only instead of c1's two-gap reason.)
| # | Submitted unit | Factor basis | Rule | Needs | Outcome |
|---|---|---|---|---|---|
| a1 | kg | kg | E = q × factor → delegates to the existing _material_result(spec, q) |
none | COMPUTED/PROVISIONAL as today |
| a2 | t | kg | q_kg = q × 1000, then a1 |
none (exact SI) | as a1. Not reachable in this PR: t is not in accepted_units. The rule is defined so it is not invented later. |
| a3 | m2 | m2 (not thickness-scalable) | E = q × factor |
none | COMPUTED |
| b1 | m2 | m2, thickness-scalable (reference_thickness_m set) |
E = q × (t_project / t_ref) × factor |
project thickness (a sourced or user-attested project input) | missing → BLOCKED row: "thickness required for a thickness-scaled per-m² factor" |
| b2 | m3 | m2, thickness-scalable | E = (q / t_ref) × factor (volume ÷ reference thickness = declared units) |
t_ref from the basis block |
COMPUTED. This is the natural route for quantities' glass_m3. |
| b3 | kg | m2 | E = (q / kg_per_declared_unit) × factor |
declared_unit_basis.kg_per_declared_unit |
block absent → BLOCKED row |
| b4 | m2 | kg | E = q × kg_per_m2 × factor |
a sourced grammage for this product. A kg-basis factor has no basis block, so this is BLOCKED in this PR. | BLOCKED row with DECLARED_UNIT_PENDING_REASON (string unchanged) |
| c1 | m3 | kg | E = q × ρ × factor |
a sourced density record (§3c) | none admissible today → BLOCKED |
| c2 | m3 | m2 (not scalable) | rejected | — | ValueError: no thickness semantics |
| — | L, u, ml, … | any | rejected | — | ValueError (422 at the API) |
Hard rules:
- No implicit conversion. Every non-identity row records the conversion it applied: the factor, its value and its source. That data goes into the audit trail. The result-schema field that carries it is added by the first PR that actually performs a conversion (see §5 for why it is not added here).
- No default parameter. A missing grammage, thickness or density is never filled in. It produces a BLOCKED row, the aggregate goes to
Nonethrough the existingaggregaterule (lca/_common.py), and an AR row records the gap. - No back-solved parameter. Densities or grammages derived from
LCI Finalmasses ÷quantitiesvolumes (for example 1 800 kg/m³ for earth) are inadmissible (AR-22, AR-47). - On error class. Decision #23 says a BLOCKED parameter "raises ValueError".
a1_a3's locked result policy instead turns per-material blocks into BLOCKED rows, with the total set toNone(a1_a3.py:9-18), and the PR #59 placeholder already does this for m². This spec follows the module's policy:ValueErroris reserved for inadmissible requests (a unit with no rule), and BLOCKED rows are used for admissible-but-unsourced requests. The gap stays loud because the total isNoneand the reason lists it.
3c. RAW_EARTH (m³) → EARTH (kg)¶
- Category step (AR-53): this PR adds no alias. Choosing between
RAW_EARTHandEARTHis a decision AR-53 assigns to Marco ("settle on one member … do not silently accept both").raw_earthstays non-selectable. - Unit step (rule c1): once AR-53 has settled the key, m³ earth → kg needs a density record that meets the seed-gate standard: value in kg/m³, material state (
as_placed/dry_loose/compacted; for earth this must beas_placed, AR-47(a)), citation URL/DOI, and 5-axis pedigree. The admissible sources for earth are the same as for its factor (Chiwara field data, CRAterre, RILEM TC 274-TCE, Ben-Alon et al.). No ÖKOBAUDAT/ICE (decision #26). - Outcome today: even with a density,
EARTH.factor is None(decision #26 hard block). An earth row is therefore BLOCKED, and its reason lists both gaps, factor (AR-03/AR-47b) and density (AR-47a), so that sourcing one of them does not hide the other. - The
DensityRecordschema and its store are out of scope for this PR. This PR defines the rule and the BLOCKED outcome only; the follow-up PR that adds the record and the AR-53 alias implements c1.
4. Edge and failure cases¶
| Case | Where caught | Behaviour |
|---|---|---|
Seed file has no declared_unit block |
runtime gate + CI | SeedEpdGateError naming the file. CI red. If wired, startup fails. |
declared_unit.qty ≤ 0, non-numeric, bool, NaN/inf |
runtime gate + CI | SeedEpdGateError |
declared_unit.unit ∉ |
runtime gate + CI | SeedEpdGateError |
declared_unit_basis present but malformed, or its citation ≠ source_citation |
runtime gate + CI | SeedEpdGateError |
Per-m²/m³ file wired without declared_unit_basis |
registry load | SeedEpdGateError at import (startup failure, as today for any non-mass unit) |
Inline _Factor declares basis ≠ KG |
_Factor.__post_init__ |
ValueError at import |
| Submitted unit has no rule (cement in m², paint in L) | API 422 / ValueError backstop |
unchanged |
| Rule exists but its parameter is missing (b1 without thickness, b3 without grammage, b4, c1 without density) | _apply_factor |
BLOCKED row, reason names the parameter and its AR; total None |
| m³ submitted against a non-scalable m² basis (c2) | _apply_factor |
ValueError. In this PR it is unreachable through the API, because accepted_units does not list m³. |
A per-m² factor is read as per-kg somewhere (SeedFactor.factor_per_kg on an m² seed) |
property | ValueError, never a silently wrong number |
Glass seed wired with reference_thickness_m unset |
gate on the wiring PR (glass.json prose says "1 mm") |
the wiring PR must set it. Its test asserts that glass.json's basis is thickness-scalable. |
5. KS parity invariant¶
Invariant: after the PR, every Keur Songho path produces byte-identical output:
backend/tests/golden/*.json: no file modified.git diff --exit-code origin/main -- backend/tests/golden/must pass.backend/tests/fixtures/test_a1_a3_material_database.py,test_a1_a3_divergence.py,test_quantities_divergence.py: unmodified.- Regression suites
test_a1_a3_keur_songho.py,test_quantities_keur_songho.py,test_a4_keur_songho.py,test_keur_songho.py,test_kalan_deme_so.py: unmodified and green.
Why the new path is a no-op for every existing number:
- Quantities path (
_KS_QUANTITIES→_accum_to_result): the PR does not touchbackend/app/domains/quantities/. Nothing else is needed. - A1-A3 KS path (
_KEUR_SONGHO_WEIGHTS_KG→compute_a1_a3→_material_result):compute_a1_a3is not routed through_apply_factor, and_material_result's expressionweight_kg * spec.factoris not edited. All 18_Factorrows default tobasis=KG. - A4 reads
KEUR_SONGHO_WEIGHTS_KG(a4.py:61,:458), which is untouched. - Seed floats: for
kg/tthe registry expression staysdeclared_gwp / (float(qty) * _KG_PER_MASS_UNIT[unit]), the same operands in the same order, so CEMENT, STEEL_REBAR, ROCK_STONE and AGGREGATE_FINE get identicalfloats. compute_a1_a3_submitted: rule a1 delegates to_material_result, so kg rows give identical results. m² rows onDECLARED_PER_M2hit rule b4 (kg basis, no grammage) and return the same BLOCKED row via_declared_unit_pending_result, with the sameDECLARED_UNIT_PENDING_REASONstring. Because the basis rule runs before the factor-is-None check (§3), this holds forCERAMIC_TILE(factor=None) as well as GLASS and PLASTIC_FILM.test_project_compute.py:95-105stays green without edits.- No response-schema change.
MaterialA1A3Resultgets no new field in this PR. Pydantic would serialise a newNonefield into every golden and break invariant 1. Thebasis/ conversion audit fields reach the API in the first wiring PR, which regenerates goldens on purpose, with the diff reviewed.
6. Test plan¶
New file backend/tests/unit/test_declared_unit_seam.py, plus additions to existing gate tests. The new unit-case fixtures live in backend/tests/data/seed_epds/ (marked $test_only) and in-memory _Factors. No fixture enters backend/app/data/seed_epds/.
Gate (runtime: new backend/tests/unit/test_seed_epd_gate.py; CI: test_seed_epd_integrity.py)
(Note: seed_epd_gate.py:34-36 says test_seed_epd_gate.py asserts the rubric-version subset, but no such file exists under backend/tests/. The PR creates it and adds that assertion too.)
1. Missing declared_unit → rejected (both halves).
2. qty ∈ {0, -1, "1", true, NaN} → rejected.
3. unit ∈ {"piece", "m", "L", "M2"} → rejected (vocabulary is case-sensitive, matching UnitEnum).
4. Malformed declared_unit_basis, or citation ≠ source_citation → rejected.
5. All eight vendored seed files pass the tightened gate, unchanged (parametrised over SEED_EPD_DIR).
Registry (test_seed_epd_registry.py)
6. An m² fixture loads with basis == M2 and factor == declared_gwp / qty. factor_per_kg on it raises.
7. Wiring an m² fixture without declared_unit_basis raises SeedEpdGateError (replaces/extends test_load_raises_on_per_area_declared_unit, :227).
8. Bit-identity: for each of the four wired seeds, SEED_FACTORS[c].factor == raw_gwp / (qty * {kg:1, t:1000}[unit]) using ==, not approx.
Multiply rules (test_declared_unit_seam.py), one test per table row:
9. a1: kg × kg-basis _apply_factor result == _material_result result (model equality) for all 18 categories.
10. a3: m² × m²-basis = q × factor.
11. b1: thickness-scaled with no thickness → BLOCKED, reason names "thickness". b1 with thickness: q × t/t_ref × factor.
12. b2: m³ × thickness-scalable m² = q / t_ref × factor. Use a hand-computed case such as glass-like t_ref=0.001, factor 3.06, 0.06 m³ → 183.6.
13. b3: kg × m²-basis with grammage = q / g × factor. Without the block → BLOCKED.
14. b4: m² for GLASS/PLASTIC_FILM/CERAMIC_TILE → BLOCKED with DECLARED_UNIT_PENDING_REASON (exact string).
15. c1: m³ × kg-basis → BLOCKED, reason names "density". For EARTH the reason names both the factor block and the density gap.
16. c2 and unruled units → ValueError.
17. _Factor(basis=M2, source_epd_id=None, factor=1.0, …) → ValueError at construction.
18. Any BLOCKED row → total is None, and the row is listed in incomplete_reason.
Invariant proof
19. The CI step (or a test) asserts git diff --exit-code origin/main -- backend/tests/golden/ backend/tests/fixtures/ on the PR branch. Run it locally before pushing and quote the empty output in the PR body.
20. The full existing suite is green with zero edits to the files listed in §5.
7. Implementation checklist: one PR, one concern¶
Branch: feat/phase1-declared-unit-seam from a fresh origin/main.
Title: feat(lca): declared-unit seam — basis-aware factors and gate (AR-46 mechanism).
-
schemas/proxy_provenance.py: addDeclaredUnitBasisandDECLARED_UNIT_VOCAB. -
services/seed_epd_gate.py: rule 7 (declared unit present, positive qty, unit in vocab) and rule 8 (optional basis block parses, citation matches). -
tests/data/test_seed_epd_integrity.py: mirror rules 7–8. -
services/seed_epd_registry.py:SeedFactor.basis/factor/basis_conversion;factor_per_kg→ guarded property;_factor_per_kg→_factor_per_basis(mass arithmetic verbatim); wiring a non-mass file requires the basis block. -
domains/lca/a1_a3.py:_Factor.basis(default KG) andbasis_conversion; the__post_init__rule;_apply_factorwith rows a1–c2;compute_a1_a3_submittedcalls_apply_factor.compute_a1_a3,_material_result,_KEUR_SONGHO_WEIGHTS_KG,accepted_units,DECLARED_PER_M2andDECLARED_UNIT_PENDING_REASONare not edited. - Tests 1–20 (§6).
$test_onlyfixtures go inbackend/tests/data/seed_epds/. -
METHODOLOGY.md/docs/methodology/material-impact-database-spec.md: add a short "factor basis and conversions" subsection pointing here. Formula verdict: the conversion rules are new formulas, not Excel reconciliations, so they have no parity target. Their tests are the hand-computed cases in §6. -
docs/architecture/module-map.md/data-flow.md: note the basis step in the A1-A3 data path. Status unchanged (no new module). - Fix the stale
seed_epd_registry.py:15-16docstring (steel profile is per-kg). -
docs/status/action-register.md: AR-46 note "mechanism landed; mass-path rebuild still open". New rows: (i) structureddeclared_unit_basisfor glass/tile/film, re-read from the live datasets (SOURCING, Track-B sub-chat); (ii)DensityRecordschema + store (DEV, Claude Code); (iii) project glazing thickness as a project input for rule b1 (DEV + UI). AR-53 unchanged (alias still Marco's decision). -
ROADMAP.mdsession-log entry;pytest,ruff check .,ruff format --check .,mypy backend/app; push; watch CI green.
Explicitly out of this PR (each is its own PR, in this order):
- (A) Seed-data PR (sub-chat): add declared_unit_basis to glass.json (reference_thickness_m: 0.001), ceramic_tile.json and plastic_film.json, each re-read from the cited ÖKOBAUDAT dataset. Touches seed_epds/ only.
- (B) Wire CERAMIC_TILE (AR-05 reconciliation doc first; CORRECTED + divergence test), GLASS (after AR-59's supersede-or-correct decision) and PLASTIC_FILM (after AR-51). Each adds the result-schema audit field on first use and regenerates goldens with a reviewed diff.
- (C) AR-53 alias + DensityRecord + rule c1 wiring, together with the AR-46 mass-path rebuild.