SKILL.md v7 sustainability
EPD Collect Artifacts
Download and verify the best-matching EPD PDF per product and write epd-download.json.
No reviews yet 0 installs
epdcollectdownloadsustainabilitylca
Versions
- v7 # Changelog ## 0.10.0 - Supplier attribution gate on both product-specific tiers: a candidate declaring the requested product but issued by a different supplier is rejected, naming the holder it declared, and the tier falls through — ending at the industrial proxy rather than attributing another manufacturer's number to this supplier. Rejection is on positive conflict only; an unidentifiable holder keeps the document as `unconfirmed`. - New `supplier_mismatch` terminal status, outranking `product_mismatch` (right product, wrong maker is the more specific finding). Not a successful status, so the next tier always runs. - Entries carrying a document gain `attribution` and `declaredHolder`. - The manufacturer-EPD tier now keeps `primaryStatus`/`primaryAttempts` on the entry when a lower tier wins: the winning tier owns `attempts`, so a rejection the industrial proxy then papered over was being dropped from the audit entirely. - The industrial tier is deliberately not attribution-gated. - Attribution narrowings from review: a shared word must identify the company rather than the sector (`_GENERIC_WORDS`), a holder must read as an organisation before it may conflict (`_looks_like_organisation`), every holder line is parsed and resolved asymmetrically (`declared_holders_of`), the supplier-domain flag upgrades but never overrides a conflicting holder, and a bare name mention only confirms inside the document's first 1000 characters (`IDENTITY_CHARS`). - A terminal `supplier_mismatch` entry carries the `declaredHolder` it rejected, so the Library remark names that holder instead of falling back to "another manufacturer". ## 0.9.0 - New vault tier (`collect_vault_tier`): when search hands a product `vaultCandidates`, the local file is **copied** into the product's deliverable folder (`copy_vault_document`, filename sanitised exactly like a download destination, named `vault-<filename>`) — it is never referenced in place, since the vault source is a read-only projection and the deliverable must stand alone. The document is deliberately **not re-verified**: `epd-scan-vault` already applied the same product-name and A1-A3 boundary predicates (pinned by that bundle's parity tests) and `epd-search-sources` already acted on its verdict, so a second extraction could only disagree with a decision already acted on, with nothing left to fall back to. - New online PCF/LCA tier, tried after the manufacturer-EPD tier and before industrial: `verify_pcf_lca` requires **both** a product-name match and an explicit A1-A3 / cradle-to-gate boundary statement. Rejection names the boundary TEXT actually found (e.g. `"declared boundary A1-C4 is not comparable to A1-A3"`, `"declared boundary cradle-to-grave is not comparable to A1-A3"`), via the new `boundary_detail_of`, rather than the bare internal enum — and falls through to the industrial tier rather than failing the product. - New statuses `vault_epd`, `vault_pcf_lca`, `downloaded_pcf_lca`. Tier order per product is now vault → manufacturer EPD → product PCF/LCA → industrial proxy, each only attempted once the previous tier produced nothing usable. A tier that ran and failed keeps its status and reasons on the entry (`vaultStatus`/`vaultAttempts`, `pcfStatus`/`pcfAttempts`, mirroring the existing `industrialStatus`/`industrialAttempts`) so the audit can always say which tiers were tried. A boundary-rejected vault document (from search's `vaultRejected`) rides along onto the entry too, so the Library step can say the vault was consulted even when its document never short-circuited. - The industrial tier now has a VAULT branch: a vault `category` document (offered first within `industrialCandidates`, ahead of the registry hits) is copied without re-verification via `collect_vault_industrial_tier`, exactly like the product-specific vault tier — previously the industrial tier was URL-driven only, so a vault candidate's deliberately-empty `url` made it invisible to `dedupe_candidates_by_url`/`_has_usable_url` and it was silently dropped before any download was ever attempted. - Each tier's download destination is now prefixed per tier (`pcf-rank{n}-`, `industrial-rank{n}-`; the vault tier already used `vault-`). Previously the product-specific and PCF tiers both wrote `rank1-{filename}` into the same `dest_dir`, so `download_pdf`'s `dest.is_file()` acceptance could hand the PCF tier the EPD tier's already-downloaded file. - The head-text window for the name-match and boundary gates is now a named `HEAD_CHARS = 8000`, matching `epd-scan-vault`'s window exactly (was a bare `4000` literal) — the two bundles could otherwise disagree about the same document when its boundary statement sat between char 4000 and 8000. - `SKILL.md` updated: the four-tier order, the new status table rows, and that vault documents are copied (never re-verified or referenced in place). ## 0.8.0 - `epd-download.json` gains a `health` block (`entriesWithAttempts`, `transientOnlyEntries`, `transientOnlyRatio`, `downloaded`) on every run. - When > 80% of attempted entries failed with transient-only reasons the step prints the `environment_unhealthy` signal and exits 4 (artifact still written for forensics). Health classification uses a superset of the retry markers adding 422 (retry behavior unchanged). The `downloaded == 0` `environment` note is unchanged. ## 0.7.1 - `download_pdf`'s urllib fallback now records non-PDF bytes (`urllib: fetched N bytes are not a PDF`) like every other fetch failure — previously it returned False without logging, so a resolved document link that served WAF/rate-limit HTML produced an attempt naming only the stale resolve-stage reasons (2026-10-01 run). ## 0.7.0 - EPD Hub document endpoints (`/declarations/file/download/…`, no `.pdf` suffix) are now recognised as document URLs — previously every EPD Hub registry-page candidate resolved to "no document link" even when the page linked its declaration (2026-09-30 run: all 11 industrial proxies died here). - Registry-page resolution gains a broker-free last resort: a direct `urllib` page fetch, harvesting href/src links and the registries' RELATIVE embedded document paths (Environdec `/api/v1/EPDLibrary/…/Documents`, EPD Hub `/declarations/file/download/…`), absolutised against the page URL, with validity dates parsed from the same HTML. - When the industrial tier runs and fails, the audit entry now keeps `industrialStatus` and `industrialAttempts` — the tier's per-candidate reasons were previously discarded, leaving unapplied proxies undiagnosable. ## 0.6.0 - Registry-page resolution parses EPD validity dates ("valid from … to …", "dash ranges", "valid until …", ISO and DD.MM.YYYY) into the audit entry (`validFrom`/`validTo`); unparseable dates are recorded as absent, never guessed. - Candidate selection stays rank-first; within a rank, known-valid candidates are tried before unknown and unknown before known-expired. A verified-but- expired winner yields to fresher candidates within the existing 3-attempt budget; when no fresher candidate verifies, the expired EPD is kept and the build step flags it in the Library remarks. - Audit entries gain `materialCategory` (all tiers) and, for `downloaded_industrial_proxy`, `confidence` + `confidenceRationale`. ## 0.5.0 - After product-specific candidates fail, try `industrialCandidates` with relaxed verification. - New status `downloaded_industrial_proxy` and `matchTier: industrial_proxy`. ## 0.4.0 - Emit per-product progress JSON on stdout during `collect_artifacts` (flushed) for long runs. ## 0.3.0 - Attempt ordering is **by rank first**; whether a candidate already has a direct PDF link only breaks a tie between candidates of the same rank. Fetch cost previously dominated rank, so three direct-PDF candidates could exhaust the 3-attempt cap before the best-ranked candidate -- typically the registry page whose title matched the product -- was ever fetched. - Product verification now compares normalized text (casefolded, `+` read as `plus`, punctuation removed), so `FOAMGLAS(R) T3+` matches a request for `Foamglas T3+`, as do the trademark mark, non-breaking spaces, hyphen variants and line wraps. A miss after normalization is still a genuine rejection. - Candidates sharing a URL are collapsed to the best-ranked one: they are the same document, and attempting both consumed two attempts and resolved to a single shared destination file. - Candidate selection now tries up to 3 ranked candidates per product (preferring ones with a direct PDF link) and **verifies** each downloaded PDF's declared product text against the requested product name before accepting it, instead of committing to a single best-ranked candidate unchecked. - New status vocabulary: `downloaded`, `downloaded_unverified`, `no_epd_candidate`, `no_pdf_url`, `download_failed`, `product_mismatch`. Every entry except `no_epd_candidate` now carries an `attempts` list recording why each tried candidate was rejected. - A verifier that fails to run (broker/extractor failure) is reported as `downloaded_unverified`, never as a rejected match — an infrastructure failure is not evidence about the document. - Extension-less registry document URLs (e.g. Environdec's `.../Documents` endpoint) are now fetched directly; the downloaded bytes' `%PDF-` header is the authority on whether a fetch produced a real PDF, not the URL shape or a content-type header. ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 10/13/2026
- v6 # Changelog ## 0.9.0 - New vault tier (`collect_vault_tier`): when search hands a product `vaultCandidates`, the local file is **copied** into the product's deliverable folder (`copy_vault_document`, filename sanitised exactly like a download destination, named `vault-<filename>`) — it is never referenced in place, since the vault source is a read-only projection and the deliverable must stand alone. The document is deliberately **not re-verified**: `epd-scan-vault` already applied the same product-name and A1-A3 boundary predicates (pinned by that bundle's parity tests) and `epd-search-sources` already acted on its verdict, so a second extraction could only disagree with a decision already acted on, with nothing left to fall back to. - New online PCF/LCA tier, tried after the manufacturer-EPD tier and before industrial: `verify_pcf_lca` requires **both** a product-name match and an explicit A1-A3 / cradle-to-gate boundary statement. Rejection names the boundary TEXT actually found (e.g. `"declared boundary A1-C4 is not comparable to A1-A3"`, `"declared boundary cradle-to-grave is not comparable to A1-A3"`), via the new `boundary_detail_of`, rather than the bare internal enum — and falls through to the industrial tier rather than failing the product. - New statuses `vault_epd`, `vault_pcf_lca`, `downloaded_pcf_lca`. Tier order per product is now vault → manufacturer EPD → product PCF/LCA → industrial proxy, each only attempted once the previous tier produced nothing usable. A tier that ran and failed keeps its status and reasons on the entry (`vaultStatus`/`vaultAttempts`, `pcfStatus`/`pcfAttempts`, mirroring the existing `industrialStatus`/`industrialAttempts`) so the audit can always say which tiers were tried. A boundary-rejected vault document (from search's `vaultRejected`) rides along onto the entry too, so the Library step can say the vault was consulted even when its document never short-circuited. - The industrial tier now has a VAULT branch: a vault `category` document (offered first within `industrialCandidates`, ahead of the registry hits) is copied without re-verification via `collect_vault_industrial_tier`, exactly like the product-specific vault tier — previously the industrial tier was URL-driven only, so a vault candidate's deliberately-empty `url` made it invisible to `dedupe_candidates_by_url`/`_has_usable_url` and it was silently dropped before any download was ever attempted. - Each tier's download destination is now prefixed per tier (`pcf-rank{n}-`, `industrial-rank{n}-`; the vault tier already used `vault-`). Previously the product-specific and PCF tiers both wrote `rank1-{filename}` into the same `dest_dir`, so `download_pdf`'s `dest.is_file()` acceptance could hand the PCF tier the EPD tier's already-downloaded file. - The head-text window for the name-match and boundary gates is now a named `HEAD_CHARS = 8000`, matching `epd-scan-vault`'s window exactly (was a bare `4000` literal) — the two bundles could otherwise disagree about the same document when its boundary statement sat between char 4000 and 8000. - `SKILL.md` updated: the four-tier order, the new status table rows, and that vault documents are copied (never re-verified or referenced in place). ## 0.8.0 - `epd-download.json` gains a `health` block (`entriesWithAttempts`, `transientOnlyEntries`, `transientOnlyRatio`, `downloaded`) on every run. - When > 80% of attempted entries failed with transient-only reasons the step prints the `environment_unhealthy` signal and exits 4 (artifact still written for forensics). Health classification uses a superset of the retry markers adding 422 (retry behavior unchanged). The `downloaded == 0` `environment` note is unchanged. ## 0.7.1 - `download_pdf`'s urllib fallback now records non-PDF bytes (`urllib: fetched N bytes are not a PDF`) like every other fetch failure — previously it returned False without logging, so a resolved document link that served WAF/rate-limit HTML produced an attempt naming only the stale resolve-stage reasons (2026-10-01 run). ## 0.7.0 - EPD Hub document endpoints (`/declarations/file/download/…`, no `.pdf` suffix) are now recognised as document URLs — previously every EPD Hub registry-page candidate resolved to "no document link" even when the page linked its declaration (2026-09-30 run: all 11 industrial proxies died here). - Registry-page resolution gains a broker-free last resort: a direct `urllib` page fetch, harvesting href/src links and the registries' RELATIVE embedded document paths (Environdec `/api/v1/EPDLibrary/…/Documents`, EPD Hub `/declarations/file/download/…`), absolutised against the page URL, with validity dates parsed from the same HTML. - When the industrial tier runs and fails, the audit entry now keeps `industrialStatus` and `industrialAttempts` — the tier's per-candidate reasons were previously discarded, leaving unapplied proxies undiagnosable. ## 0.6.0 - Registry-page resolution parses EPD validity dates ("valid from … to …", "dash ranges", "valid until …", ISO and DD.MM.YYYY) into the audit entry (`validFrom`/`validTo`); unparseable dates are recorded as absent, never guessed. - Candidate selection stays rank-first; within a rank, known-valid candidates are tried before unknown and unknown before known-expired. A verified-but- expired winner yields to fresher candidates within the existing 3-attempt budget; when no fresher candidate verifies, the expired EPD is kept and the build step flags it in the Library remarks. - Audit entries gain `materialCategory` (all tiers) and, for `downloaded_industrial_proxy`, `confidence` + `confidenceRationale`. ## 0.5.0 - After product-specific candidates fail, try `industrialCandidates` with relaxed verification. - New status `downloaded_industrial_proxy` and `matchTier: industrial_proxy`. ## 0.4.0 - Emit per-product progress JSON on stdout during `collect_artifacts` (flushed) for long runs. ## 0.3.0 - Attempt ordering is **by rank first**; whether a candidate already has a direct PDF link only breaks a tie between candidates of the same rank. Fetch cost previously dominated rank, so three direct-PDF candidates could exhaust the 3-attempt cap before the best-ranked candidate -- typically the registry page whose title matched the product -- was ever fetched. - Product verification now compares normalized text (casefolded, `+` read as `plus`, punctuation removed), so `FOAMGLAS(R) T3+` matches a request for `Foamglas T3+`, as do the trademark mark, non-breaking spaces, hyphen variants and line wraps. A miss after normalization is still a genuine rejection. - Candidates sharing a URL are collapsed to the best-ranked one: they are the same document, and attempting both consumed two attempts and resolved to a single shared destination file. - Candidate selection now tries up to 3 ranked candidates per product (preferring ones with a direct PDF link) and **verifies** each downloaded PDF's declared product text against the requested product name before accepting it, instead of committing to a single best-ranked candidate unchecked. - New status vocabulary: `downloaded`, `downloaded_unverified`, `no_epd_candidate`, `no_pdf_url`, `download_failed`, `product_mismatch`. Every entry except `no_epd_candidate` now carries an `attempts` list recording why each tried candidate was rejected. - A verifier that fails to run (broker/extractor failure) is reported as `downloaded_unverified`, never as a rejected match — an infrastructure failure is not evidence about the document. - Extension-less registry document URLs (e.g. Environdec's `.../Documents` endpoint) are now fetched directly; the downloaded bytes' `%PDF-` header is the authority on whether a fetch produced a real PDF, not the URL shape or a content-type header. ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 10/13/2026
- v5 # Changelog ## 0.8.0 - `epd-download.json` gains a `health` block (`entriesWithAttempts`, `transientOnlyEntries`, `transientOnlyRatio`, `downloaded`) on every run. - When > 80% of attempted entries failed with transient-only reasons the step prints the `environment_unhealthy` signal and exits 4 (artifact still written for forensics). Health classification uses a superset of the retry markers adding 422 (retry behavior unchanged). The `downloaded == 0` `environment` note is unchanged. ## 0.7.1 - `download_pdf`'s urllib fallback now records non-PDF bytes (`urllib: fetched N bytes are not a PDF`) like every other fetch failure — previously it returned False without logging, so a resolved document link that served WAF/rate-limit HTML produced an attempt naming only the stale resolve-stage reasons (2026-10-01 run). ## 0.7.0 - EPD Hub document endpoints (`/declarations/file/download/…`, no `.pdf` suffix) are now recognised as document URLs — previously every EPD Hub registry-page candidate resolved to "no document link" even when the page linked its declaration (2026-09-30 run: all 11 industrial proxies died here). - Registry-page resolution gains a broker-free last resort: a direct `urllib` page fetch, harvesting href/src links and the registries' RELATIVE embedded document paths (Environdec `/api/v1/EPDLibrary/…/Documents`, EPD Hub `/declarations/file/download/…`), absolutised against the page URL, with validity dates parsed from the same HTML. - When the industrial tier runs and fails, the audit entry now keeps `industrialStatus` and `industrialAttempts` — the tier's per-candidate reasons were previously discarded, leaving unapplied proxies undiagnosable. ## 0.6.0 - Registry-page resolution parses EPD validity dates ("valid from … to …", "dash ranges", "valid until …", ISO and DD.MM.YYYY) into the audit entry (`validFrom`/`validTo`); unparseable dates are recorded as absent, never guessed. - Candidate selection stays rank-first; within a rank, known-valid candidates are tried before unknown and unknown before known-expired. A verified-but- expired winner yields to fresher candidates within the existing 3-attempt budget; when no fresher candidate verifies, the expired EPD is kept and the build step flags it in the Library remarks. - Audit entries gain `materialCategory` (all tiers) and, for `downloaded_industrial_proxy`, `confidence` + `confidenceRationale`. ## 0.5.0 - After product-specific candidates fail, try `industrialCandidates` with relaxed verification. - New status `downloaded_industrial_proxy` and `matchTier: industrial_proxy`. ## 0.4.0 - Emit per-product progress JSON on stdout during `collect_artifacts` (flushed) for long runs. ## 0.3.0 - Attempt ordering is **by rank first**; whether a candidate already has a direct PDF link only breaks a tie between candidates of the same rank. Fetch cost previously dominated rank, so three direct-PDF candidates could exhaust the 3-attempt cap before the best-ranked candidate -- typically the registry page whose title matched the product -- was ever fetched. - Product verification now compares normalized text (casefolded, `+` read as `plus`, punctuation removed), so `FOAMGLAS(R) T3+` matches a request for `Foamglas T3+`, as do the trademark mark, non-breaking spaces, hyphen variants and line wraps. A miss after normalization is still a genuine rejection. - Candidates sharing a URL are collapsed to the best-ranked one: they are the same document, and attempting both consumed two attempts and resolved to a single shared destination file. - Candidate selection now tries up to 3 ranked candidates per product (preferring ones with a direct PDF link) and **verifies** each downloaded PDF's declared product text against the requested product name before accepting it, instead of committing to a single best-ranked candidate unchecked. - New status vocabulary: `downloaded`, `downloaded_unverified`, `no_epd_candidate`, `no_pdf_url`, `download_failed`, `product_mismatch`. Every entry except `no_epd_candidate` now carries an `attempts` list recording why each tried candidate was rejected. - A verifier that fails to run (broker/extractor failure) is reported as `downloaded_unverified`, never as a rejected match — an infrastructure failure is not evidence about the document. - Extension-less registry document URLs (e.g. Environdec's `.../Documents` endpoint) are now fetched directly; the downloaded bytes' `%PDF-` header is the authority on whether a fetch produced a real PDF, not the URL shape or a content-type header. ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 10/8/2026
- v4 # Changelog ## 0.5.0 - After product-specific candidates fail, try `industrialCandidates` with relaxed verification. - New status `downloaded_industrial_proxy` and `matchTier: industrial_proxy`. ## 0.4.0 - Emit per-product progress JSON on stdout during `collect_artifacts` (flushed) for long runs. ## 0.3.0 - Attempt ordering is **by rank first**; whether a candidate already has a direct PDF link only breaks a tie between candidates of the same rank. Fetch cost previously dominated rank, so three direct-PDF candidates could exhaust the 3-attempt cap before the best-ranked candidate -- typically the registry page whose title matched the product -- was ever fetched. - Product verification now compares normalized text (casefolded, `+` read as `plus`, punctuation removed), so `FOAMGLAS(R) T3+` matches a request for `Foamglas T3+`, as do the trademark mark, non-breaking spaces, hyphen variants and line wraps. A miss after normalization is still a genuine rejection. - Candidates sharing a URL are collapsed to the best-ranked one: they are the same document, and attempting both consumed two attempts and resolved to a single shared destination file. - Candidate selection now tries up to 3 ranked candidates per product (preferring ones with a direct PDF link) and **verifies** each downloaded PDF's declared product text against the requested product name before accepting it, instead of committing to a single best-ranked candidate unchecked. - New status vocabulary: `downloaded`, `downloaded_unverified`, `no_epd_candidate`, `no_pdf_url`, `download_failed`, `product_mismatch`. Every entry except `no_epd_candidate` now carries an `attempts` list recording why each tried candidate was rejected. - A verifier that fails to run (broker/extractor failure) is reported as `downloaded_unverified`, never as a rejected match — an infrastructure failure is not evidence about the document. - Extension-less registry document URLs (e.g. Environdec's `.../Documents` endpoint) are now fetched directly; the downloaded bytes' `%PDF-` header is the authority on whether a fetch produced a real PDF, not the URL shape or a content-type header. ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 9/22/2026
- v3 # Changelog ## 0.3.0 - Attempt ordering is **by rank first**; whether a candidate already has a direct PDF link only breaks a tie between candidates of the same rank. Fetch cost previously dominated rank, so three direct-PDF candidates could exhaust the 3-attempt cap before the best-ranked candidate -- typically the registry page whose title matched the product -- was ever fetched. - Product verification now compares normalized text (casefolded, `+` read as `plus`, punctuation removed), so `FOAMGLAS(R) T3+` matches a request for `Foamglas T3+`, as do the trademark mark, non-breaking spaces, hyphen variants and line wraps. A miss after normalization is still a genuine rejection. - Candidates sharing a URL are collapsed to the best-ranked one: they are the same document, and attempting both consumed two attempts and resolved to a single shared destination file. - Candidate selection now tries up to 3 ranked candidates per product (preferring ones with a direct PDF link) and **verifies** each downloaded PDF's declared product text against the requested product name before accepting it, instead of committing to a single best-ranked candidate unchecked. - New status vocabulary: `downloaded`, `downloaded_unverified`, `no_epd_candidate`, `no_pdf_url`, `download_failed`, `product_mismatch`. Every entry except `no_epd_candidate` now carries an `attempts` list recording why each tried candidate was rejected. - A verifier that fails to run (broker/extractor failure) is reported as `downloaded_unverified`, never as a rejected match — an infrastructure failure is not evidence about the document. - Extension-less registry document URLs (e.g. Environdec's `.../Documents` endpoint) are now fetched directly; the downloaded bytes' `%PDF-` header is the authority on whether a fetch produced a real PDF, not the URL shape or a content-type header. ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 9/1/2026
- v2 # Changelog ## 0.2.0 - Write `/workspace/work/code/epd-download.json` download audit JSON instead of `epd-matches.xlsx` / `epd-matches.md`. Primary selection requires a candidate `pdfUrl`; products without one are recorded as `no_epd_candidate`. ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 8/28/2026
- v1 # Changelog ## 0.1.0 - Initial release: `lib/collect_artifacts.py` reads `/workspace/work/code/epd-products.json` and `/workspace/work/code/epd-candidates.json`, selects the lowest-ranked candidate with a `pdfUrl` (or page `url` when no PDF URL exists), downloads PDFs to `/workspace/work/outputs/epds/<productId>/`, and writes `/workspace/work/outputs/epd-matches.xlsx` plus `epd-matches.md`. Uses broker download helpers when available; never invents EPD metadata or PDF bytes. 8/27/2026