Skip to content

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 has factor=None and 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-16 lists "steel profile" among the per-m² records. steel_profile.json declares 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's declared_unit is {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_provenance present and parsing as ProxyProvenance (cascade step, 5 Weidema axes 1–5, justification ≥ 280 chars, cross-field rules), approved programme operator, supported rubric version, and the shape of source_citation.
  • CI half: backend/tests/data/test_seed_epd_integrity.py:90-165 checks 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 in CATEGORY_SEED_FILES. A seed file with no declared_unit, or with unit: "piece", passes the gate and CI today. ProxyProvenance has no unit field (grep declared\|unit schemas/proxy_provenance.py finds nothing).

0.4 RAW_EARTH (m³) vs EARTH (kg), AR-53

  • quantities emits earth as (MaterialCategory.RAW_EARTH, acc.earth_m3, UnitEnum.M3) (backend/app/domains/quantities/__init__.py:600).
  • A1-A3 keys earth as MaterialCategory.EARTH with factor=None, BLOCKED under the decision #26 hard block (a1_a3.py:208-229). The KS mass is EARTH: 1_494_888.525 kg (a1_a3.py:480).
  • Nothing maps one key to the other. lci_materials marks raw_earth as selectable: false and points users to earth (backend/app/services/lci_materials.py:37-45).
  • No citable earth density exists. The 1 800 kg/m³ implied by LCI Final is 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 factor is per. It is set once, at load:
  • mass declared units (kg, t) → normalised to basis kg. This uses the existing arithmetic, unchanged (seed_epd_registry.py:160).
  • m2, m3 → basis = the declared unit, stored natively as declared_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_citation must equal proxy_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_m set → 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, in backend/app/schemas/proxy_provenance.py, next to ProxyProvenance.

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, M2 or M3) and factor: float (per basis).
  • Keep factor_per_kg as a property that returns factor when basis is KG and raises ValueError otherwise. 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. For kg/t the expression stays literally declared_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_seed sets basis=seed.basis.
  • Add basis_conversion: DeclaredUnitBasis | None = None, passed through from the seed.
  • __post_init__ gains one rule: basis ∈ {KG, M2, M3}. If basis is not KG and factor is not None, then source_epd_id must 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) for DECLARED_PER_M2 and (KG,) otherwise, so the request contract and the 422 behaviour stay as they are. Deriving it from basis and 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:

  1. Category alias: none in this PR (see 3c).
  2. Unit admissibility: quantity.unit ∉ accepted_units(category) → ValueError (unchanged). The API catches this as a 422 first.
  3. 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.
  4. Factor present? Applies only to rule a1 (same-unit) rows: spec.factor is None → BLOCKED row carrying the record's own blocked_reason (unchanged behaviour; _material_result already does this). Rules a2, a3, b1–b3 multiply by spec.factor only 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 None through the existing aggregate rule (lca/_common.py), and an AR row records the gap.
  • No back-solved parameter. Densities or grammages derived from LCI Final masses ÷ quantities volumes (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 to None (a1_a3.py:9-18), and the PR #59 placeholder already does this for m². This spec follows the module's policy: ValueError is 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 is None and the reason lists it.

3c. RAW_EARTH (m³) → EARTH (kg)

  • Category step (AR-53): this PR adds no alias. Choosing between RAW_EARTH and EARTH is a decision AR-53 assigns to Marco ("settle on one member … do not silently accept both"). raw_earth stays 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 be as_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 DensityRecord schema 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:

  1. backend/tests/golden/*.json: no file modified. git diff --exit-code origin/main -- backend/tests/golden/ must pass.
  2. backend/tests/fixtures/test_a1_a3_material_database.py, test_a1_a3_divergence.py, test_quantities_divergence.py: unmodified.
  3. 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 touch backend/app/domains/quantities/. Nothing else is needed.
  • A1-A3 KS path (_KEUR_SONGHO_WEIGHTS_KG → compute_a1_a3 → _material_result): compute_a1_a3 is not routed through _apply_factor, and _material_result's expression weight_kg * spec.factor is not edited. All 18 _Factor rows default to basis=KG.
  • A4 reads KEUR_SONGHO_WEIGHTS_KG (a4.py:61, :458), which is untouched.
  • Seed floats: for kg/t the registry expression stays declared_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 identical floats.
  • compute_a1_a3_submitted: rule a1 delegates to _material_result, so kg rows give identical results. m² rows on DECLARED_PER_M2 hit rule b4 (kg basis, no grammage) and return the same BLOCKED row via _declared_unit_pending_result, with the same DECLARED_UNIT_PENDING_REASON string. Because the basis rule runs before the factor-is-None check (§3), this holds for CERAMIC_TILE (factor=None) as well as GLASS and PLASTIC_FILM. test_project_compute.py:95-105 stays green without edits.
  • No response-schema change. MaterialA1A3Result gets no new field in this PR. Pydantic would serialise a new None field into every golden and break invariant 1. The basis / 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).

  1. schemas/proxy_provenance.py: add DeclaredUnitBasis and DECLARED_UNIT_VOCAB.
  2. services/seed_epd_gate.py: rule 7 (declared unit present, positive qty, unit in vocab) and rule 8 (optional basis block parses, citation matches).
  3. tests/data/test_seed_epd_integrity.py: mirror rules 7–8.
  4. 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.
  5. domains/lca/a1_a3.py: _Factor.basis (default KG) and basis_conversion; the __post_init__ rule; _apply_factor with rows a1–c2; compute_a1_a3_submitted calls _apply_factor. compute_a1_a3, _material_result, _KEUR_SONGHO_WEIGHTS_KG, accepted_units, DECLARED_PER_M2 and DECLARED_UNIT_PENDING_REASON are not edited.
  6. Tests 1–20 (§6). $test_only fixtures go in backend/tests/data/seed_epds/.
  7. 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.
  8. docs/architecture/module-map.md / data-flow.md: note the basis step in the A1-A3 data path. Status unchanged (no new module).
  9. Fix the stale seed_epd_registry.py:15-16 docstring (steel profile is per-kg).
  10. docs/status/action-register.md: AR-46 note "mechanism landed; mass-path rebuild still open". New rows: (i) structured declared_unit_basis for glass/tile/film, re-read from the live datasets (SOURCING, Track-B sub-chat); (ii) DensityRecord schema + 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).
  11. ROADMAP.md session-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.