Heat Rules Worker Heat Safety Beta — under active development, not production-ready. Verify every result against the governing standard.

← Back to the calculator · Methodology

Changelog

What changed in this calculator, when, and why. Corrections that moved a threshold in the less protective direction are called out explicitly in the entries below rather than folded into a general "updated thresholds" line.

Per-standard parameters, last updated

The date each registered standard’s numeric parameters last changed, from data/changelog.json. This is not the date the standard itself was published or amended — that is on each standard page with its sources.

StandardIDParameters last updated
Andhra Pradesh State Heat Wave Action Plan 2024HGC-1292026-09-22
Belarus Hygienic Norm — Microclimatic Indicators at Workplaces (Post SovMin 37/2021)HGC-1222026-09-22
Canada Occupational Health and Safety Regulations — Thermal Stress Procedures (SOR/2026-10, COHSR s. 10.19.3)HGC-1092026-09-22
Croatia Pravilnik o zaštiti na radu za mjesta rada (NN 105/2020) — Workplace Climate DutiesHGC-1272026-09-22
Estonia Töötervishoiu ja tööohutuse seadus § 6(4) — Indoor Climate DutyHGC-1282026-09-22
FIFA / Sports Hydration Break OverlayHGC-0512026-09-22
Ireland General Application Regulations 2007 Reg. 7 — Room Temperature DutyHGC-1262026-09-22
Luxembourg ITM-ET 32.10 — 26 °C Workplace Temperature CeilingHGC-1252026-09-22
Morocco Arrêté n° 93-08 — Workplace Temperature Provisions (no heat threshold)HGC-1332026-09-22
Netherlands Arbobesluit Art. 6.1 — Workplace Temperature DutyHGC-1242026-09-22
Nevada Wildfire Smoke Protection (SB 260 / NRS 618.297; LCB R070-26, proposed)HGC-1352026-09-22
Odisha Labour & ESI Department Seasonal Midday Outdoor-Work RestrictionHGC-1322026-09-22
Tanzania Occupational Health and Safety Act 2003 — General Duty (no heat threshold)HGC-1342026-09-22
Telangana State Heatwave Action Plan 2021HGC-1302026-09-22
Uzbekistan SanPiN No. 0289-10 — Outdoor Work Temperature LimitsHGC-1182026-09-22
West Bengal Action Plan on Heat Wave 2023HGC-1312026-09-22
AAFA Guide to Protecting Workers from Heat Stress 2026HGC-0062026-09-21
Ahmedabad Heat Action Plan 2019 Update (applied across Gujarat)HGC-0262026-09-21
Algeria Decret Executif 91-05 General DutyHGC-0912026-09-21
Argentina Resolución SRT 30/2023 — Especificaciones Técnicas de Carga Térmica (Heat Stress)HGC-1022026-09-21
Armenia Labour Code Art. 153 — Special Breaks Above +40 °CHGC-1172026-09-21
Australia Thermal Work Limit (TWL)HGC-0552026-09-21
Austria "Hitzefrei" Construction Rule (BSchEG)HGC-0662026-09-21
Austria Hitzeschutzverordnung 2026HGC-0992026-09-21
Azerbaijan Labour Code Art. 233 — Work Stoppage Above 41°CHGC-1162026-09-21
Bahrain Midday Outdoor Work BanHGC-0162026-09-21
Bangladesh Labour Rules 2015 (Schedule-I)HGC-0242026-09-21
Belgium Code du bien-être au travail — WBGT Heat Stress ThresholdsHGC-0622026-09-21
Better Work / GLI Apparel Heat OverlayHGC-0502026-09-21
Brazil NR-15 Annex 3 (IBUTG)HGC-0222026-09-21
British Columbia WorkSafeBC Heat Exposure Standard (OHS Guideline G7.28)HGC-0602026-09-21
Brunei SHENA Heat Stress GuidanceHGC-0132026-09-21
Bulgaria Naredba № RD-07-3 of 18.07.2014 — Minimum Requirements for the Microclimate of WorkplacesHGC-1102026-09-21
Cal/OSHA Indoor Heat Illness Prevention (Title 8 §3396)HGC-0212026-09-21
Cal/OSHA Outdoor Heat Illness Prevention (Title 8 §3395)HGC-0712026-09-21
Cal/OSHA Protection from Wildfire Smoke (Title 8 §5141.1)HGC-0742026-09-21
Chile DS 594 Heat Stress Standard (TGBH/WBGT Limits)HGC-0612026-09-21
China GBZ 2.2-2007 Heat Stress LimitsHGC-0392026-09-21
Colombia Resolución 2400 de 1979 — Heat Stress ProvisionsHGC-0692026-09-21
Colorado Agricultural Labor Conditions Rules (7 CCR 1103-15 Rule 3)HGC-0722026-09-21
Costa Rica Thermal Stress Prevention Rule (Decreto No. 39147-S-TSS)HGC-0642026-09-21
CPWR / OSHA-CPWR Construction Heat OverlayHGC-0482026-09-21
Cyprus Code of Practice for Thermal StressHGC-0652026-09-21
Czech Government Regulation No. 361/2007 Coll. — Occupational Heat Load (Annex 1 Part A)HGC-1032026-09-21
Denmark Arbejdstilsynet Indoor-Climate Temperature GuidanceHGC-1062026-09-21
Ecuador Norma Técnica de Seguridad e Higiene del Trabajo — Art. 8 Heat (WBGT Ceilings)HGC-1012026-09-21
Egypt Ministerial Decree No. 211 of 2003HGC-0422026-09-21
El Salvador Decreto No. 89 (TGBH)HGC-0252026-09-21
EU Framework Directive 89/391/EECHGC-0362026-09-21
Finland Työsuojelu Kuumatyö (Hot Work) Indoor Break ScheduleHGC-0872026-09-21
France Decret n° 2025-482 Heat DutiesHGC-0332026-09-21
Germany ASR A3.5 Room Temperature RuleHGC-0292026-09-21
Germany ASTA Outdoor Heat Recommendation (Beurteilungstemperatur)HGC-1132026-09-21
Greece Ministry Heatwave Emergency RuleHGC-0352026-09-21
Hong Kong Labour DepartmentHGC-0092026-09-21
Hungary 3/2002 SzCsM-EüM — Minimum Health and Safety Requirements for Workplaces (Climate Provisions)HGC-1112026-09-21
India WBGT Work/Rest Bands (unverified)HGC-0072026-09-21
Indonesia Permenaker No. 5/2018 (ISBB)HGC-0462026-09-21
ISO 7243:2017 WBGT ScreeningHGC-0192026-09-21
ISO 7933 Predicted Heat Strain (PHS)HGC-0542026-09-21
Italy Regional Anti-Heat Ordinances (Lazio, Lombardy, Piedmont, Campania)HGC-0982026-09-21
Italy Worklimate / Regional Anti-Heat OverlayHGC-0342026-09-21
Japan MHLW WBGT Warning SystemHGC-0182026-09-21
Kenya OSH Act General DutyHGC-0372026-09-21
Kerala Seasonal Midday Outdoor-Work Rest Order (Minimum Wages Act, 1958, ss. 24-25)HGC-0862026-09-21
Kuwait Noon Work BanHGC-0172026-09-21
La Isla Network-informed RSH-s Default ProtocolHGC-0012026-09-21
Latvia Cabinet Regulation No. 359 — Workplace Microclimate LimitsHGC-0842026-09-21
Lithuania HN 69:2003 Indoor Temperature Ceilings + VDI Outdoor Heat-Break GuidanceHGC-0882026-09-21
Maharashtra State Heat Wave Action Plan 2024 (SDMA)HGC-1082026-09-21
Malaysia DOSH Heat Stress Guidelines 2016HGC-0202026-09-21
Malta OHSA Construction Heat-Stress Instrument (Admin Instrument No. 2 of 2025)HGC-0802026-09-21
Maryland OSHA Heat Stress Standard (COMAR 09.12.32)HGC-0582026-09-21
Mexico NOM-015-STPS-2001 (IBUTG)HGC-0412026-09-21
Minnesota OSHA Indoor Ventilation and Temperature Rule (5205.0110)HGC-0592026-09-21
Moldova HG 353/2010 — Maximum Workplace Air Temperature by Metabolic RateHGC-1212026-09-21
MSHA Mining Heat Stress OverlayHGC-0492026-09-21
Nevada OSHA Heat Illness Prevention Regulation (R131-24)HGC-0732026-09-21
NFL / American Football Heat Practice OverlayHGC-0522026-09-21
Nicaragua Resolución Ministerial sobre Higiene Industrial — Heat Stress (Chapter XV)HGC-0682026-09-21
NIOSH 2016-106 Table 6-2HGC-0042026-09-21
NIOSH REL/RAL Logarithmic CurveHGC-0052026-09-21
Norway Arbeidstilsynet Workplace Temperature GuidanceHGC-1072026-09-21
NYC Executive Order No. 17 — Outdoor Worker Heat ProtectionHGC-0772026-09-21
Oil & Gas Heat Governance OverlayHGC-0532026-09-21
Oman Midday Work BanHGC-0152026-09-21
Ontario OHCOW Humidex Heat Response PlanHGC-0672026-09-21
Oregon OSHA Heat Illness Prevention Standard (AO 3-2022)HGC-0572026-09-21
Oregon OSHA Protection from Wildfire Smoke (OAR 437-002-1081)HGC-0752026-09-21
OSHA Federal Heat Rule NPRM (Proposed)HGC-0472026-09-21
OSHA Heat National Emphasis Program (CPL 03-00-024, April 2026)HGC-1052026-09-21
Paraguay Decreto N° 14.390/92 — TGBH (WBGT) Exposure Limits and Work/Rest Table (Art. 228)HGC-1142026-09-21
Philippines DOLE Labor Advisory No. 08-23HGC-0232026-09-21
Phoenix Contractor Outdoor Heat Safety OrdinanceHGC-1152026-09-21
Poland Maximum Workplace Temperature Rule (§30a, Dz.U. 2026 poz. 927)HGC-0952026-09-21
Poland Preventive Meals & Drinks Regulation (Dz.U. 1996 Nr 60 poz. 279)HGC-0832026-09-21
Portugal Decreto-Lei n.º 243/86 — Temperature & Humidity Comfort RuleHGC-1122026-09-21
Qatar Ministerial Decision No. 17 of 2021HGC-0112026-09-21
Québec Regulation respecting occupational health and safety (RRQ S-2.1, r. 13) — Schedule V Heat StressHGC-1002026-09-21
Romania OUG nr. 99/2000 — Extreme Temperature Worker ProtectionHGC-0812026-09-21
Russia Rospotrebnadzor Heat-Work RecommendationsHGC-0962026-09-21
Safe Work Australia Heat GuideHGC-0302026-09-21
San Antonio Contractor Outdoor Heat OrdinanceHGC-0782026-09-21
Saudi MHRSD Heat Stress RuleHGC-0142026-09-21
Serbia Pravilnik on Preventive Measures — Workplace Air Temperature LimitHGC-1202026-09-21
Singapore MOM / WSH (WBGT ≥ 32°C)HGC-0102026-09-21
Slovakia Decree No. 99/2016 Coll. — Protection against Heat and Cold Load at WorkHGC-1042026-09-21
Slovenia Workplace Heat Rules (Pravilnik, Uradni list RS 53/2025)HGC-0792026-09-21
South Africa Physical Agents Regulations 2024HGC-0432026-09-21
South Korea KOSHA GUIDE W-12-2017 WBGT TableHGC-0452026-09-21
South Korea MoEL Heat-Work RuleHGC-0282026-09-21
Spain Outdoor Heat Amendment (RD 486/1997)HGC-0322026-09-21
Sweden AFS 2023:12 — Time-Weighted Heat Index (Work in Intense Heat)HGC-0822026-09-21
Switzerland SUVA/SECO Heat at Work — Four Heat Levels (Arbeiten im Freien bei Sonne und Hitze)HGC-1192026-09-21
Taiwan Article 303-1 Heat-Index Control OverlayHGC-0442026-09-21
Taiwan High-Temperature Work/Rest StandardHGC-0272026-09-21
Tamil Nadu Heat Wave Action Plan (TNSDMA) + BOCW Outdoor-Work Timing RestrictionHGC-0852026-09-21
TB MED 507 Heat CategoriesHGC-0032026-09-21
Thailand Occupational Heat Standard 2006HGC-0402026-09-21
Turkey Law No. 6331 General DutyHGC-0382026-09-21
UAE Midday Break RuleHGC-0122026-09-21
UK HSE Temperature GuidanceHGC-0312026-09-21
Ukraine ДСН 3.3.6.042-99 — Sanitary Norms for Microclimate of Industrial PremisesHGC-0972026-09-21
Uruguay MTSS — Guía para trabajos con exposición a altas temperaturas (2025)HGC-1232026-09-21
US Navy / Marine Corps Ashore Flag ConditionsHGC-0632026-09-21
Vietnam QCVN 26:2016/BYTHGC-0082026-09-21
Washington L&I / DOSH Wildfire Smoke Rule (WAC 296-820)HGC-0762026-09-21
Washington State DOSH Outdoor Heat Standard (WAC 296-62-095)HGC-0562026-09-21

Release history

[Unreleased] - WBGT Estimator: Liljegren Globe Model, Reference Parity, And Published ACGIH Action Limits

Removed - September 24, 2026

  • heatrules.com no longer serves the La Isla Network pages; every /lin/ page URL now 404s. The crew guidance page, the staff console and the reduced calculator are published only to laislanetwork.org/guidance/ from the guidance repository, whose console authenticates with the X-LIN-Staff-Token header. public/lin/, the dist/lin/ build, the Worker's /lin/guidance/<a>/<b>/ shell rewrite, the service worker's /lin exclusion, scripts/lin-header.mjs, scripts/lin-header-check.mjs, scripts/lin-validate.mjs and the page-only tests are gone from this repository (they live in the guidance repository). Every API these pages call stays: the /api/v1/lin/ routes (including site-access), weather, air quality, geo, wbgt-history, wbgt-history-chart, the LIN_DB D1 database and the lin_backups R2 bucket. Deploy only after the guidance repository is live on laislanetwork.org.

Fixed - September 22, 2026

  • ACGIH Table 3 values are now read as ceilings, not floors, so every ACGIH answer is one band stricter — on the calculator, the composite, the site planner and /lin. Each value is the highest effective WBGT at which its work allocation passes screening (ACGIH 2024 TLVs and BEIs, Heat Stress and Strain, Method 1: below the criteria "there is little risk"); the table values themselves are unchanged. FORMULAS.computeACGIH returns the most-work row not exceeded (<=, per the CCOHS worked example: moderate, acclimatized — 28 °C passes 75–100 %, 31 °C only 0–25 %, 33 °C nothing) and, above the 0–25 % value, Exceeds screening criteria at 0/60: the +2.0 °C HALT margin had no basis in the source and is gone. Blank cells are skipped per Note 6 (cool-hour Heavy work now tops out at 50–75 %, Very Heavy at 25–50 %), so the * "one band less restrictive" extrapolation is gone too, and every note says the result is a screening result rather than a prescribed schedule (Note 7). Brunei, Malaysia and BC, which reuse the engine, move with it. Canada COHSR needed its own fix: it engaged its implementation duty on thresholdC != null, which the ceiling reading makes always true; it now engages once WBGTeff exceeds the first published ceiling. Site-planner goldens moved protective-only (Saigon May 2025, moderate unacclimatized: 0.57 → 0.23 h/day of labor, full stop-work days 9 → 17). The one boundary that loosened is the exact 75–100 % value, which now passes at 60/0 instead of 50/10. Ported from the guidance repository alongside a public/lin re-sync; pinned by test/acgih-screening.test.js.
  • Legal dates are now verified at the precision their own evidence states, and a rule that is not law no longer reads "in force". The quote check accepted any quote containing the instrument's four-digit year, and a year appears in every citation — which made the one fabrication check the exact case it could not catch. Three "verified" pages accordingly asserted a date their own source did not: Uzbekistan SanPiN 0289-10 (the quote states 14 Oct 2010, the page printed 15 Nov), Belarus Mikroklimat Post 37 (the quote states the 25 Jan 2021 adoption, the page printed the 6 Jun effective date) and FIFA's sports standard (the quote states no date at all). quoteStatesDate in scripts/guards/legal-dates.mjs is now precision-aware: a bare year satisfies only a year-precision date, a month-precision date needs the year and the month, and a day-precision date needs the day and month in either field order with a 2- or 4-digit year or a named month beside the day number; the verified anchor is now the date the page renders (effective || adopted) and never lastAmended, which does not print. The three rows re-gate to "unverified" with their dates kept (the registry is 114 dated and 12 verified). The date row's label is gated on legalStatus in scripts/prerender.mjs — a regulation reads "In force since", guidance and technical standards read "Published", a proposed rule reads "Proposed", and historical or source-needs-verification standards get no date row at all — so a supervisor is no longer told that OSHA's 2024 proposal or a superseded standard is in force. apply-legal-dates.mjs now refuses a worksheet whose rows carry no legalDates object, because that shape would have written an empty block to every standard in one pass.

Changed - September 22, 2026

  • The README's architecture and route map were brought up to the current tree. It now documents 123 registered calculators/overlays rather than 88, the real Worker route table (twenty exact routes plus the evaluate dynamic route, the /api/v1/lin/* subtree and the gated first-party endpoints), the corrected API request/response flow, and the live component trees (NearbyGuidancePanel, MapEngine, RecordActions, the feature-gated view tabs). The counts were spot-checked against the tree rather than carried forward — 123 standards, 10 STATE_SCOPED_ENGINE_IDS, 16 documented OpenAPI paths.

Added - September 21, 2026

  • AAFA's September 2026 Heat Stress Toolkit now sets a risk level on every aafa_2026 reading, and it extends the existing engine rather than adding a second AAFA row. The Toolkit (Heat Stress Management Guidelines for Manufacturers V1) is a companion to — not a replacement of — the Guide v2.0 of April 2026 whose Table 2 the engine already encodes, so it is registered as a second source on aafa_2026 instead of the separate aafa_heat_toolkit_2026 engine the research dossier proposed. A second engine would have put one authority in the comparison table twice and risked double-counting it in the composite, which is the risk the dossier itself flagged as a maintainer decision; that open question is now recorded as resolved in docs/research/aafa_2026-update-2026-09-18.md. What the Toolkit adds is the four risk levels an area sits in — LOW below 25 °C, MODERATE 25–28 °C, HIGH 28–30 °C, VERY HIGH / EXTREME above 30 °C WBGT (Heat Index equivalents: below 32.8, 32.8–39.4, 39.4–46.1, above 46.1 °C) — because crossing a band is the event that activates a facility's action plan. The published edges overlap and the code says which way they resolve: 28 °C is printed in both the MODERATE and HIGH rows and resolves upward (the more protective reading), while the top row is "Above 30 °C", so exactly 30.0 °C is still HIGH. The Toolkit publishes levels, not measures — every action plan is written by the facility and is mostly administrative — so it contributes no work/rest minutes: the schedule still comes from Guide v2.0 Table 2, and a test fails if that ever stops being true. A missing WBGT returns no level rather than LOW, because an unmeasured area is not a safe one.
  • The two AAFA documents publish two different hourly fluid ceilings, and the lower one is carried instead of the newer one. Guide v2.0 Table 2 caps intake at 1.5 US qt/hr (1419 mL); the Toolkit says never more than 1.5 L in any single hour. They are not averaged and the newer document does not simply win: the hazard this ceiling exists for is hyponatremia from over-drinking, which the larger number makes worse. The Toolkit's per-shift figure — above about 6 L across a shift it declares the heat controls failed and the area in need of engineering intervention — is carried as controlFailureShiftMl, replacing the Guide's much higher "12 quarts daily" for that purpose. Nothing in the composite hydration path changes: the tables it takes a maximum over top out at 1.0 qt/hr, well under either ceiling.
  • The Toolkit is heat only — it carries no PM2.5 or AQI trigger, so nothing here touches the smoke overlay. It is voluntary industry guidance and says so itself: "Where national or local law is more strict, national or local law applies."
  • Netherlands, Luxembourg and Ireland workplace-temperature engines (HGC-124–126), added after every one of the dossier's eight open questions was closed against a primary source. Luxembourg's 26 °C ceiling is encoded with its own parenthetical branch: an indoor reading above 26 °C is waived only when the outdoor temperature also exceeds 26 °C, flagged when it does not, and reported unresolved when no outdoor reading was supplied, because guessing either way misstates the duty; legalStatus is guidance rather than regulation, since guichet.public.lu grounds employer heat obligations in the Code du travail and the Règlement grand-ducal du 4 novembre 1994 without citing 26 °C, and that regulation's text could not be retrieved to check for a competing provision (the gap is stated in the registry, not papered over). The Netherlands Arbobesluit Art. 6.1 members were read from the official consolidated text and carry no numeric value, so duty-only is now verified and the widely repeated "28 °C office maximum"/"40 °C stop-work" claims are confirmed absent. Ireland's Reg. 7 (settling a Reg. 7 vs Reg. 11 discrepancy between the Irish Statute Book's "as made" text and the Law Reform Commission consolidation, whose text is identical) contains no maximum, verified by full-text search rather than assumed, and the engine says so explicitly because for an Irish user that absence is the answer. None of the three produces work/rest minutes or stop-work, and all three stay out of the composite. A gate caught one thing: test/outreach-render.test.js requires a docs/outreach/authority-contacts.csv row per registered standard, a fifth sync location none of the existing lessons named (recorded as L-016).
  • Morocco, Tanzania and Nevada engines (HGC-133–135), and three declined dossiers closed so the queue stops calling them implement-ready. Both African dossiers rested on a bad source mapping, and reading the primary texts overturned them: Morocco's Arrêté 93-08 Art. 14 — which an ILO LEGOSH index presented as an extreme-temperature rule — is a cold provision that heat-encloses workplaces when the temperature drops and shelters site watchmen from the cold, with a full-text scan of all 19 pages finding no heat trigger and only Art. 23's 18 °C accommodation minimum; Tanzania's OSH Act s. 46, which LEGOSH describes as addressing extreme temperatures, is the steam-boiler provision ("Every steam boiler and all its fittings..."), and the word "temperature" appears zero times across sections 19–78 (the Act is a scan with no text layer, rendered and OCR'd), leaving s. 54's clean-water duty as its only heat-relevant obligation. Both are therefore registered as general-duty overlays that state the absence plainly, modelled on the registered Algeria and Kenya engines, because telling a Moroccan or Tanzanian user "no heat threshold exists here" is the answer and silence would read as unfinished research. Nevada is registered proposed_rule, not regulation: adoption was re-checked independently on 2026-09-21 and only R070-26P and R070-26I exist while the adopted-filing suffixes all 404, so the thresholds are settled but not in force — exactly two, AQI 150 and 500 with no intermediate 200 tier, under NRS 618.297 in force since 2026-01-01 — and AQI 500 bars only tasks the employer has itself identified as critical in its own plan, never a blanket stop-work. Cuba (thresholds are from the 2008 CTN-6 draft, not the paywalled 2011 issue), Peru (RM 150-2026 is a meteorological hazard scale, not an occupational instrument) and Paraguay (already covered by paraguay_ds14390) are declined with their dispositions recorded; queue 10 → 9.
  • India's Factories Act 1948 is recorded as statutory context, explicitly not as provenance, for the legacy india_bis WBGT bands. Read in full from the DGMS-hosted PDF (60 pages, amendments footnoted through Act 20 of 1987): s. 13 requires ventilation and a temperature securing reasonable comfort and preventing injury to health, and delegates any actual standard to the State Governments — which is why the Act carries no number and the states' wet-bulb ceilings do; s. 15 governs artificial humidification; s. 16 per-worker air volume; s. 18 requires wholesome drinking water at marked points and, above 250 workers in hot weather, cool drinking water (the Act's one heat-conditional duty); s. 47 requires rest rooms kept cool and clean above 150 workers. Every duty is qualitative, so nothing becomes a threshold, and the Act binds factories only (s. 2(m), mines expressly excluded); sourceConfidence stays "unverified" and legalStatus "historical", so this evidences nothing about the bands. lastChecked is null because dgms.gov.in reset the connection from this repository's tooling.

Changed - September 21, 2026

  • Each standard's page now carries its legal dates, and 114 of 117 do. The registry had no legal-date field — when an instrument was adopted or entered into force existed only as prose in details, country_guidance.md and the research dossiers — so /standards/<id>/ could not say whether the rule a supervisor is reading was five years old or fifty. legalDates is the first registry field asserting a fact about the outside world rather than about this project, and no engine reads it, so a wrong year would never turn a behavioural test red; it therefore carries its own evidence and is fenced accordingly — check 12 in scripts/guards/legal-dates.mjs requires all six sub-keys, partial-ISO dates at the precision the source gave, a sourceId that resolves in data/sources.json, and for a verified row a date, a source and a quote carrying that date's own precision. scripts/agent/apply-legal-dates.mjs is the only path from the research worksheet into the registry (confidence can only fall, an engine's own parameters date always wins, a malformed date is dropped rather than coerced into a precision nobody read), and precheck-legal-dates.mjs is the deterministic half that fetches each cited page and reports whether the quote and the date are on it, grading rather than deciding. Two defects in the gate surfaced when good evidence refused to verify, both now pinned by tests: normalizeUrl dropped the query string, collapsing WAC 296-62-095 and 296-62-09510 — two different Washington rules — onto one key and linking a date to the wrong instrument (only the fragment is dropped now); and the quote check demanded a four-digit year, rejecting the literal citation "filed 6/4/08, effective 7/5/08" (a two-digit year now counts, but only as part of a full day-precision date). precheck-legal-dates.mjs reads PDFs through pdftotext, since several official journals publish the governing text only that way. Coverage went 106 → 114 standard pages carrying an "In force since" row; the three without one are evidenced absences rather than outstanding work (India's india_bis has no instrument behind its bands, Norway's figures state no publication or revision date, Switzerland's SUVA Massnahmenplan is templates whose only date is a file timestamp), unverified dates print labelled "(unverified)" rather than withheld, and the row is anchored to the date the page renders so a quote proving when a rule was filed does not verify when it took effect. legalDates is NON_MATERIAL in the fingerprint — three engines gate on a date and each keeps its own constant — so backfilling 117 provenance blocks did not fire 117 "this standard changed" items (SCENARIO_SET_VERSION 4 → 5 re-baselines; npm run check-events reports 0 fingerprints moved).
  • Five dossiers the research queue called "awaiting implementation" had been registered for months under a different id, so the queue was reading id drift as backlog. gen-research-index.mjs matches the dossier's "Proposed engine id" literally in data/standards-index.json, so an implementation that renamed the id left its dossier forever pending: czechia_nv_361_2007 → czech_nv_361_2007, finland_kuumatyo → finland_tyosuojelu_kuumatyo, denmark_arbejdstilsynet_temperatur → denmark_at_indoor_temperature, lithuania_hn69_vdi_heat → lithuania_hn69, latvia_mk359_microclimate → latvia_mk_359. Each pair was checked to be the same instrument, and in three cases the registered engine already answers the gap its dossier flagged as blocking. Rather than give the queue a second place to drift, each dossier now names the id actually used and keeps the proposed one beside it; the Nordics and Visegrad dossiers leave the queue and the queue falls 14 → 13 with no engine written.
  • Sources: a dead URL repaired, two cosmetic redirects corrected, and a freshness-sweep gap recorded. hungary_3_2002_szcsm_eum moves to njt.jog.gov.hu, the current canonical domain confirmed by tavily_extract as the identical consolidated text (njt.hu returned 503/timeout in three probes); cyprus_mlsi_heat and tamil_nadu_bocw_heat_notification_2024 were updated to their live 200-redirect targets rather than relying on the redirect every week; kerala_labour_heat_order_2026's apparent redirect was investigated and deliberately left, since both locale paths resolve with matching content. Separately, check-source-freshness.mjs's isBotWallShaped() allowlists only 403/unreachable and never 502/503/504, so the UAE midday rule and Bulgaria both re-fire as unverified every week despite independent live confirmation — recorded as a lesson and flagged for a maintainer decision rather than re-litigated each sweep.

Changed - September 20, 2026

  • The Today tab's world map is now Mapbox too. Both surfaces that render GuidanceMap — the Standards list and the jurisdiction map under the Today tab's results — run the same Mapbox GL engine, so a reader who taps a pin on one and a pin on the other is reading one map rather than two that disagree about projection, basemap and territory outlines. This reverses the September 17 split recorded below, which kept the Today tab on the Robinson SVG map because it is the offline-capable working surface. That argument is not discarded, it is delegated to the fallback that already existed: mapboxDown swaps back to SvgMapInline when GL JS cannot load, when the style returns 401/403, or when nothing has rendered after 15 s. Offline, the CDN script fails on its first request, so the swap is immediate rather than a blank box waiting out the deadline — the precached SVG base is still exactly what an offline Today tab draws. The cost the split was avoiding is real and is now paid: ~1.9 MB of GL JS on the calculator's primary screen. It is lazy-loaded at the map's mount, below the results rather than at boot, and it is still not precached, but the byte count is no longer zero on that screen. privacy.html is updated in the same change, because its third-party table said the GL library and its events.mapbox.com telemetry ran "only on the Guidance Wiki"; that claim became false the moment this map changed engines.
  • One address per standard, and the standards list is a real page. The reference list left the #guidance fragment for /standards/ — the path its per-standard pages already used — and is labelled Standards in the nav and the tab bar. The URL serves the interactive list (map, search, filters) with the prerendered index inside the page body, so a crawler reads the whole list without JavaScript and the app replaces it on mount; the site no longer publishes two different indexes of the same thing, one crawlable and one linked. The search box is ?q= instead of #guidance?q=. Every legacy fragment still lands: #guidance and #guidance?q= are rewritten to the path, #guidance/<id> to that standard's page, and an unknown id to the list rather than a 404. Card anchors are #s-<standardId> — one standard is one map pin, so the pin key the old anchor carried identified nothing.
  • The withheld standard has a page. ACGIH's TLV/Action Limit rule is evaluated by the calculator but its screening tables are licensed, so it had no page — which made "every standard has a page" false and cost the app a fallback link, a focused card and a redirect that declined to redirect. It now has a stub page of its own that names the rule, explains why its numbers are not printed there, and links ACGIH. No table, no calculator, no feed, and absent from the sitemap. Nothing about what the engine computes has changed.
  • The Today tab links out to the rules it quotes. The comparison row, the detail cards and the source panel's caveat list name a standard; each of those names is now a link to that standard's page, where the record sits beside its sources and a calculator for one reading. Previously only the hero overlay offered a way through.

Added - September 20, 2026

  • MET Norway's Locationforecast 2.0 is now the worldwide WBGT forecast fallback behind Open-Meteo. The chain was Open-Meteo then NWS, and NWS is US-only, so outside its coverage a 5xx from Open-Meteo ended the lookup at "enter a measured WBGT manually" — no forecast at all for the crews this tool exists for. MET Norway is global, keyless and ECMWF-based: a different model on different infrastructure, so it is unlikely to be down for the same reason. Three gaps the provider leaves are closed rather than papered over. It reports no radiation of any kind, so solar is modelled from cloud cover and solar geometry on the same path the NWS fallback uses and the reading lands in the partial-confidence tier with a warning saying so. Pressure is reported at sea level only and is reduced with the ISA formula using the model terrain altitude the response carries (Denver: 1018.3 → 838.8 hPa, which is the station pressure Denver reports). And every timestamp is UTC with no timezone, so a longitude-derived offset — which would be about two hours wrong in Spain and would report the afternoon peak as a late-morning reading, an understatement in the unsafe direction — is avoided by a timeapi.io zone lookup applied per row through Intl so a DST change inside the window lands on the right hour; if the lookup fails the response degrades instead of lying, returning the current hour picked on the absolute UTC axis with an empty hourlyWbgt and a warning. Only the leading hourly run is served (the 6-hourly tail past ~2.5 days is dropped rather than interpolated), and because MET Norway publishes forward hours only, a shift already under way comes back short with the hour it starts at named rather than as a part-chart with nothing under it. api.met.no does not bill us, it blocks us, so the terms-of-service obligations are in code and marked TOS: at each site — identifying User-Agent with contact details, coordinates truncated to 4 decimals (5+ is a 403, and 4 is ~11 m), no repeat request before the Expires header, a logged warning on a 203 deprecation status, and no retry on a 429. The data is CC BY 4.0, so the response carries an attribution string that credits MET Norway, links the licence and states that the WBGT is computed here — the licence's "indicate if changes were made" clause and the provenance claim in one sentence.
  • The per-rule calculator now reads in the unit the rule is published in. The control on /standards/<id>/ was always °C, including on rules whose thresholds are not stated in °C: Georgia By-Law 2.67 (sports_nfl) modifies football practice at 82 / 87 / 90 / 92 °F WBGT, which on a 0.1 °C slider sit at 27.78 / 30.56 / 32.22 / 33.33 °C, so a reader could not land on a single published boundary — dialling 27.8 returned the band above. resolveCalcAxis (scripts/prerender.mjs) restates the primary axis in °F for the six rules whose own compute* function converts that input to °F and compares it against its constants (sports_nfl, us_navy_ashore_flags, tb_med_507, niosh_table, cal_osha_indoor, washington_osha_outdoor); membership is read off the engine, not judged from the prose, so minnesota_osha_indoor and aafa_2026 — whose band edges are arithmetically °F conversions but compare in °C — are deliberately left out pending a primary-source check, and india_bis is not claimed to be Fahrenheit either. The engine input contract is unchanged: both slider bounds are the published °C bounds converted, standard-calculator.js converts back to °C in buildInput, and the page carries a one-line note naming which scale is which.
  • The map now outlines the territory each guidance dot governs. A dot at a jurisdiction centroid does not say which place it governs, and the three federal pins make that worse rather than better: US/CA/IN sit at the seat of government, not the centre, and are the same three countries that also carry state-level dots — the US federal pin and the Maryland pin land 0.98 viewBox units apart, under a fifth of a marker radius, so at default zoom they are one blob. At rest a navy hairline is drawn around each pinned sub-national region (only the pinned regions, never all 50 states: drawing exactly the places that have their own rule is the information); on selection, focus or hover that one territory is outlined in --teal-active with a 10% fill, keyed selectedPinKey || focusedKey || hoveredKey with selection first because an open popup is a stronger commitment than a resting pointer. Per-dot colours were rejected on two grounds: 89 guidance pins is far past the point categorical colour stops being decodable, and this product's colour vocabulary is severity, so tinting Arizona red on a heat map would state a cease-work verdict over a whole jurisdiction. Geometry needs no new data — all 72 countries and 17 regions already ship in data/geo/, and geo-resolve.js's memoized loader is reused at idle — with a projected-path fallback where the base SVG carries no group, and the Mapbox side carries COUNTRY_WORLDVIEW_FILTER so highlighting India does not stack the Kashmir polygons. test/map-outline.test.js fails the build if a standard ever adds a jurisdiction without a shape. Coverage-gap pins get no outline (they are researched absences of guidance and have no geometry), and the outline surfaced two pre-existing over-claims rather than fixing them: eu_framework_89_391 is listed as applying everywhere when it applies to the EU, and italy_regional_heat_ordinance governs four regions but pins at Italy.

Fixed - September 20, 2026

  • The /standards/ shell is revalidated at the edge, and the withheld standard's stub page is noindex. The shell names the same fingerprinted assets as /, so it needs the same max-age=0 rule or a cached copy points at deleted assets after a deploy; the withheld stub is an address rather than a page to rank, already out of the sitemap and now noindex, follow. Two test-only recoveries from local-only branches landed the same day: Norway's success case now asserts the metric-safety warning does not appear when operativeTempC is supplied (a result that both computes a schedule and says the metric is not a valid substitute is unreadable, and nothing on main had caught it), and the Czech metricSafety guard is now asserted at the dispatch layer, so a wrapper that dropped metricSafety and emitted a bare recommendation would fail; the same test also pins invalidVelocity carrying metricValidated:false with no table2Exceeded verdict.

Added - September 19, 2026

  • The generated reference pages got a long-form reading tier. The ~207 prerendered pages (117 standards, 72 countries and the hubs) are drawn by one inline <style> block in scripts/prerender.mjs that was written for a different artifact: measured before, eleven distinct font sizes (9/10/11/12/12.5/13/14/15/16/20/26px) across 30 hardcoded declarations, no container font-size so unclassed prose inherited the UA's 16px and "Required inputs" rendered larger than the 13px lede, most of the page at the UA's normal line-height, body copy at 105–115 characters a line, and one Norway source note as 2,030 unbroken characters at 11px. Now nine size tokens and two clamps, seven distinct sizes on a rendered page, 72–78 characters a line, and a line-height ladder — three sizes are genuine deviations from README_DESIGN.md's scale, recorded in its Decisions Log because these are long-form reference pages rather than the field tool that scale was written for, and the embedded calculator keeps the dense field-tool scale. The contrast finding is the serious one: .hgc-page painted body text #808285 (3.59:1) — the exact hex README_DESIGN.md already records as replaced everywhere — and it survived because test/design-contrast.test.js reads .css files while this stylesheet lives in a template literal inside a .mjs; that hole is closed, the guard was mutation-checked against the original defect, and because every ratio in the design doc is measured against --surface #FFFFFF while these pages paint --bg #F5F7F8 (where #6B7280 is 4.4989:1), the reading column is now an actual white sheet — which is what makes the published ratios true here rather than nearly true. Navigation is a section index over ids stamped on every h2, sticky beside the sheet from 1000px and a <details> strip below it, with no JavaScript. Long notes are split at render time, never in the data, because parameters is in standard-fingerprint.mjs's field list and re-flowing the prose would change fingerprints and manufacture update-history entries for a change that altered no guidance; the splitter is lossless and a test rejoins its output against the source. Two silent bugs were caught by the new tests while writing them: the two-column grid was unconditional, so /feeds/ rendered 216px wide, and five tokens renamed at the usage but not the declaration fell back to UA sizes.

Changed - September 19, 2026

  • AGENTS.md is now the single source of agent instructions, and the module-boundaries migration is planned rather than attempted. AGENTS.md and CLAUDE.md were both rulebooks and had drifted ~90 lines apart across three whole sections (the public/lin/ vendored-directory rule, Adversarial Code Audits, and Skill routing + the memory setup), and nothing could catch it: sync-skill-trees.mjs mirrors .claude/skills/ to .agents/skills/ and never read the root pair, so no drift guard was ever looking at these two files. AGENTS.md absorbs the missing sections and becomes canonical, CLAUDE.md drops to a twelve-line pointer, the judgment-call rule is made vendor-neutral ("Use the model for" rather than naming a vendor — the vendor name was exactly why the pair could never be byte-compared, hiding the real drift behind an expected difference), and test/agent-instructions.test.js enforces it (CLAUDE.md stays a pointer under 30 lines, every rule section stays in AGENTS.md, the rule names no vendor). CLAUDE_new_guidance.md was never agent instructions but a worked NYC Executive Order 17 engine plan, so it moved to docs/nyc-eo17-engine-plan.md with a header saying what it is. Separately, docs/module-boundaries-plan.md assesses splitting app.js (12.4k lines, ~76 components, no exports) and formulas.js (17.3k lines, 130 engines in one mutable global) against the constraint that index.html loads 14 first-party classic scripts in a load-bearing order; it recommends against running the extraction as a program and says so plainly, while specifying three individually-landable steps each with its break mode and the check that proves it.
  • Sources were dated from evidence already in the repo, seven authority contacts re-verified, and the prerendered pages now render against the origin serving them. 60 of 159 sources had lastChecked: null, so the offline freshness check could never gate; 46 were dated from records already in the repository — a standard's lastVerified where its sourceIds name exactly one source, the freshness allowlist's verifiedLive, or the source's own notes, each with the evidence named in docs/source-freshness-backlog.md (60/159 → 14/159, threshold untouched at 90 days) — and no date is invented, because a lastChecked asserts somebody looked that day; the remaining 14 stay null with a documented blocker each. Seven authority contacts landed after each address was re-fetched from its official domain (Turkey İSGGM, Indonesia Kemnaker, Washington L&I, Uzbekistan SSV, Serbia, Moldova, Belarus), while three proposed upgrades were not written because re-verification failed (Algeria, Taiwan, Egypt) and South Korea and CPWR stay deliberately blank. scripts/dev.mjs now passes its own origin as siteBase, because every internal link on a generated page was built from a SITE_BASE hardcoded to https://heatrules.com — so the one environment where these cross-linked pages get edited was the one where you could not walk through them; the production origin remains the default when no siteBase is passed, so scripts/build.mjs output is byte-for-byte unchanged.

Fixed - September 19, 2026

  • One gate now decides whether a standard applies, for the composite and the display alike, and a named roster can no longer smuggle in a rule the worker's location does not reach. The two surfaces disagreed: getMostProtective admitted a standard when jurisdictionApplies !== false while the comparison table used isInDisplayRegion, so at WBGT 31.5 / light / acclimatized / US-TX the LIN card read "Binding rule: Navy BUMED — 20/40" directly above the same page's "It does not apply at your worksite", and a US-military-only rule set a civilian Texas worksite's schedule (the same split for tb_med_507 and aafa_2026). FORMULAS.standardAppliesToLocation is now the whole decision and isInDisplayRegion a delegation to it — the display may depend on the engine, never the reverse — with the two surfaces differing in exactly one documented flag, failOpen: the display fails OPEN on an unprovable subdivision (never hide a rule you cannot prove irrelevant) and the composite fails CLOSED (a binding Math.min is a claim about the law), and a test enumerates every divergence across the whole registry and asserts each one is that case. With no country resolved, undefined no longer counted as applicable, so Sweden AFS 2023:12, Tamil Nadu TNSDMA, Vietnam, Qatar and Chile no longer raise the red STOP WORK card for a worker whose location is unknown; only COMPOSITE_NEUTRAL_STANDARD_IDS (La Isla, ACGIH, ISO 7243, ISO 7933) may bind, with TB MED 507 and AAFA 2026 returning a permissive 60/0 rather than military doctrine or trade-association guidance setting an unknown-location schedule. A narrowed roster, introduced so the planner's NIOSH-only and dry-bulb modes and public/lin could keep working, was unsound: it returned true for any standard named in it, and compareStandards cannot observe that a human chose the array — heat-guidance-service passes standardIds straight through from an unauthenticated public API, so POST /api/v1/compare {"standardIds":["tb_med_507"],...} returned a 60/0 composite, workBoundBy tb_med_507, jurisdictionScope "jurisdiction_neutral" for a worker with no location, and flipped 18 standards — including us_navy_ashore_flags and sweden_afs_stark_varme, which raised STOP WORK attributed to a Swedish rule for a worker whose location is unknown — from non-binding to binding; the opt-out now reaches CORE_INTERNATIONAL_STANDARD_IDS and nothing further, which keeps the planner and /lin working while the 18 leaks close. describeWorksiteJurisdiction keeps all five country-inventory statuses distinct and never flattens no_rule_found into not_researched, and formulas.js now loads country-inventory.json under Node (it was browser-only, so every server-side consumer reported "not researched" for all 32 researched countries). The jurisdiction verdict fields (jurisdictionApplies, jurisdictionScope, jurisdictionNote) are a function of the query, not of the standard, so they are dropped from the fingerprint exactly as hiddenReason already was; changing the projection re-hashed every standard and SCENARIO_SET_VERSION was bumped 3 → 4 to re-baseline rather than publish 106 simultaneous "guidance changed" items, and the re-baseline now lists every fingerprint that moved and warns loudly if a material/protectiveness delta is being suppressed. In the same pass, the comparison table now marks on each row which displayed rules do not set the schedule: the display gate fails open while the composite fails closed, so with a US country and no state 22 rules are displayed and 8 bind, and rows like Cal/OSHA 3395, Washington, Oregon, Maryland, Minnesota, Nevada, Colorado and NYC EO17 previously sat under a subtitle claiming "Scoped to the guidance that governs your location" with nothing marking them.
  • Four standards stopped printing the same source-verification paragraph in all 52 cells of their WBGT grid, and nine more stopped leaking raw JSON into a Parameter/Value cell. resultText()'s last resort was return rec.note, so any engine whose recommendation shape matched no earlier branch fell through to prose: argentina_srt_30_2023 (1,400 characters, repeated in every cell), bc_worksafebc_heat, ecuador_norma_tecnica_calor and philippines_dole. The note branch is now bounded (a note over 120 characters is not a summary) and given the three specific branches those shapes deserve — Argentina's VLA/VLP stages, WorkSafeBC's ACGIH regimen band, Ecuador's ceiling — with Argentina's wording deliberately never saying "stop work", because crossing the VLA is a control and reporting duty and crossing the VLP is an escalation with three employer-chosen routes, not a cessation order, and a cell saying otherwise would invent an obligation the resolución does not impose. The nine raw-JSON cases ({"I":[0,80],"IIa":[81,105],...}, 250 characters of punctuation in a table a supervisor is meant to read) now render for a reader: bands as ranges, an open upper bound as "301+" rather than "301, —", and a parameter that is really a small table of its own as a list rather than a 429-character sentence. Separately, a WBGT grid whose cells all say the same sentence is now dropped as a sibling to the existing dash guardrail: philippines_dole declares wbgtC required so a grid was drawn for it, but its own engine note says the advisory "names no heat metric at all" and fixes no numeric trigger, and surfacing the wrapper-dropped 25 °C heatStressLikely flag (marked "medium confidence, not primary-verified" and attributed to a different document) would have published a threshold the cited advisory does not contain — so the grid states the true thing instead, and the fallback sentence is chosen from requiredInputs so a rule that reads WBGT but states no WBGT-indexed thresholds says exactly that. Update history also moved to the bottom of the page and finally has something to say: the forward watcher started after the registry existed, so 112 of 117 standards carried exactly one event ("Added to the calculator") that read as though no rule had ever been corrected; gen-events.mjs --seed-history recovered eighteen material changes across sixteen standards from git history, deliberately without re-running the engine of the day (a behavioural fingerprint computed against today's code would be a fabrication), so each recovered event names the fields that moved and carries safetyDirection: 'unknown' — not 'none', which would assert protectiveness did not change.
  • Engine warnings are now strings on every surface, not [object Object], and the Paraguay caveat no longer publishes a false "guidance changed" item. FORMULAS.warn() objects ({level,code,message,data}) went straight into warnings, and every consumer renders or joins that array directly — none read .message — so all 20 warn() call sites produced [object Object] wherever a warning reached a surface; it only became visible once warnings rendered for applicable rows. Normalised once at commonResult(), the choke point every engine already funnels through, so no call site changed: warnings is always strings and the original entries move to a sibling warningDetails that keeps level/code/data for programmatic consumers. test/warning-shape.test.js drives all 117 engines over all 934 fingerprint scenarios (109,278 runs) and asserts every warning is a string and every warningDetails entry's .message matches, proven to fail by renaming one engine's message key (226 failures across the grid). Separately, Paraguay had published two guidance_changed items in one day while its numbers never moved — the material hash and the protectiveness vector were byte-identical, only behavior moved because a boolean flag and then a prose caveat were added — so caveat joins PROJECTION_DROP_KEYS alongside note/explanation/message; the earlier objection that this would fire a false event for osha_heat_nep, which also sets caveat, was checked rather than assumed (OSHA's caveat is a module-level constant no input can vary, and Paraguay's is derived prose over a boolean that is still hashed), so neither can change without something material changing first. In the same pass, formulas.js gives the COUNTRY_INVENTORY load its own try/catch: the Node metadata block loads data/standards-loader.js first, and in the bundled Worker that loader's dynamic require(path.join(__dirname, …)) throws on its first line with the shared catch swallowing everything after it including the inventory assignment — harmless today, but it would have answered "not researched" for all 32 countries in production while passing every Node test.
  • The per-rule calculator refuses an out-of-range entry instead of trusting the spinner bounds, and the outreach mailer no longer sends ACGIH a 404. The HTML min/max attributes bound the spinner arrows and nothing else, so a typed or pasted 200 % relative humidity fired input like any other value and reached the engine — and nothing downstream caught it, because computeISO7933's applicability check bounds air temperature, wind, metabolic rate and clothing but not humidity — producing a confident work/rest verdict for a reading no instrument can produce. The entry is now refused and named, never clamped, and the engine is not asked at all; sliders and segmented controls were never affected, since a slider cannot leave its own track. renderNotEvaluated() is shared by that path and the assertNumericInputs catch, which also fixes a stale card that left the previous verdict's severity colours in place so "Could not evaluate" could appear on Stop Work red. Separately, scripts/outreach-render.mjs linked https://heatrules.com/standards/<id>/ for every contact row including acgih_tlv_action_limit — the one id in FORMULAS.UNPUBLISHED_STANDARD_IDS, whose page this same branch excludes from prerendering and the sitemap — so the generated letter mailed ACGIH a 404 at the very address their copyright policy is the reason for; withheld ids now fall back to the #guidance/<id> route the app still serves, reading the list from formulas.js rather than copying it.
  • The calculator's own UI is now honest about what it is showing. The default-values badge ("Using default values — not your site") vanished the moment you clicked "Scenario" without editing anything — exactly when the synthetic series becomes visible and the warning matters most — because isDefaultWbgtCurve compared wbgtInputMode and the default mode is "single", and dropping the mode alone was not enough since clicking Scenario also runs showManualScenario(), which seeds the start/peak fields. The default is a flat 29.4 °C line, not the "21.7 → 29.4 curve" earlier messages described (wbgtStart only applies in start_peak mode), so the hero no longer reports "peak at 7:30 AM" on a flat series, where that was a tie artifact and not a peak. Unit handling: the Guidance Thresholds chips stayed in °C after switching to °F while the chart axis converted alongside them, and the Sun Exposure offsets were labelled in °F while the adjacent PPE offsets were in °C — those are deltas, so +13 °F is +7.2 °C, not the absolute conversion. The LIN card rendered "HIGH RISK" with a normal work schedule directly under a red STOP WORK banner; it now takes the same partition that raises the banner, so the two cannot disagree. The Paraguay very-heavy caveat was truncated by a flat 160-character collapse that cut it before the ACGIH-permits-none clause — the half that matters — so the collapse now protects the safety-relevant clause. And the inferred-jurisdiction line "Guidance for Texas, US — detected from your network. Not your site? Set location." was a plain div with no handler and no focusable child, telling a worker to correct a network-guessed jurisdiction and giving them nothing to click; describeWorksiteJurisdiction now also returns provenanceCorrection, so the UI renders the trailing phrase as a button — only when the note genuinely ends with it, so a copy edit degrades to plain text rather than a mislabelled button — and it opens the place search, scrolls it into view and focuses it. In the same pass the #6B7280 → #626875 sweep finished in app.js (65 text-only literals, including six SVG text fills and the LIN_BODY_TEXT constant, with five quoted ratio comments corrected to #626875's real values) and the comparison table was un-clipped.
  • The contrast tokens were finished, and the guard that should have caught them was rebuilt to actually catch them. --muted/--body #6B7280 cleared 4.5:1 on white and on nothing else it was actually used on (4.47:1 on --teal-wash, 4.39:1 on the chip grey, 4.26:1 on the toggle strip, 4.43:1 elsewhere), so the earlier #6B7D87 → #6B7280 swap moved ~250 nodes from one failing colour to another; both tokens moved to #626875 (same hue and saturation, lightness 46.1% → 42%), which clears all 22 light surfaces the sheet declares (worst case 4.72:1 on #DCF0EE), with the 21 hardcoded color: #6B7280 declarations now reading var(--muted). design-contrast.test.js now derives the surface set from styles.css and checks every text token against every surface it appears on, not just --surface. #6B7D87 (4.09:1, under the WCAG 1.4.3 bar) is banned as text — it was never in README_DESIGN.md's colour tables and entered from a literal — with all 20 app.js uses becoming --muted, and the ratchet now strips comments, collapses literal concatenation and converts rgb() before counting, covers all five view scripts with per-file baselines, and each evasion was introduced one at a time and confirmed to fail. #6B7280 itself joins a SUPERSEDED map with a zero baseline that may only stay zero, because it passes the old BANNED map's white-only contract while failing on 14 of the sheet's 22 real surfaces. view-tokens.js is a new shared module holding COLORS/TONE_COLORS/TONE_TEXT_COLORS for the four view scripts — reconciliation found zero value disagreements, so the drift this prevents is the next one — with TONE_LABELS deliberately not promoted because week-view and site-view genuinely disagree on two of five strings and picking one would silently change what a view says; the wiring also caught writeServiceWorker's precacheUrls and the two /lin rewriters. The Framework Comparison table was 883px inside a 708px container and clipped on load — the "Stop?" header rendered as "STO" and a red YES stop-work badge was cut in half, so the truncated column was the stop-work answer — and now steps out into the 1320px reference frame README_DESIGN.md already assigns to comparison tables rather than getting a scrollbar to hide it; footer disclaimer links no longer render browser-default blue, and the mobile tab bar no longer forces a 408px scrollWidth at a 386px viewport.
  • The build stamp can no longer be silently blank, and the invariants around load order and the build manifest now have teeth. gitLogField/gitField did catch { return '' }, so under the full suite a transient git-spawn failure became an EMPTY commitDate — the field answering "how current is this guidance" for a supervisor reading it, a fail-quiet in a traceability path rather than a cosmetic miss; both now retry three times and then distinguish the two cases: no git repository (building from a tarball) still returns '', but a failure inside a repository throws and fails the build. test/lin-build-wiring.test.js kept a hand-written copy of LIN_STAMP_INPUTS that had already drifted (view-tokens.js added to build.mjs's list and not the test's), so it now imports the exported constant. test/warning-shape.test.js walks the 117-engine × 934-scenario grid once in beforeAll instead of per-it, cutting 5s+ to 111ms while both invariants still fail loud. New test/dev-build-wiring.test.js derives script load order and the build.mjs manifest from index.html rather than a hand-kept list and asserts both, closing a gap where a module added to index.html and dev.mjs but missed in build.mjs would pass while shipping a broken production page. The app.js extraction itself was evaluated and not done: two of the plan's premises proved false — WBGTHeroSection is not a tab-level unit behind a view switch (it renders as the first child of a tabpanel, so the lazy-view pattern does not fit) and WBGTForecastChart is dead code — and an eager JSX module cannot be served by npm start.

Added - September 18, 2026

  • Every standard's page carries a live calculator for that one rule. #guidance/<id> (a fragment inside the Guidance Wiki tab that rendered a static card) now redirects to /standards/<id>/, which carries the same record plus a control that evaluates that single rule, so a worker with a reading in hand drags the metric the rule actually reads and gets that one rule's verdict instead of the most-protective composite of 110 standards. resolveCalcAxis() in scripts/prerender.mjs resolves one axis descriptor per standard from the same RECIPE_*_CONFIG/WBGT_GRID_BASELINES/SMOKE_CONFIGS/WBGT_MIN/WBGT_MAX the tables are generated from and the same eligibility predicates — 67 of 106 published standards resolve an axis, the other 39 being general-duty rules with no numeric trigger that say so — and invents no range, baseline, threshold or unit. The descriptor rides on a <div id="hgc-calc"> as data attributes rather than an inline script, so the crawlability check in test/prerender.test.js stays meaningful; standard-calculator.js (new, vanilla, no React/Babel) patches that one record into the metadata-stripped FORMULAS and calls FORMULAS.compareStandards(input, [id]) on every change, never implementing a threshold, with severity from classifyRecommendation and no colour on rank 0 because colouring an advisory rule would assert a tier the standard never states. standard-result-format.js (new) holds summarizeRecommendation/resultText, shared with the prerenderer so the live readout and the table row beneath it cannot phrase the same engine result two different ways. Fail loud: missingInputs, hiddenReason and warnings[] render as the answer, a cleared field reports absent rather than 0, and slider bounds never exceed the range the published table covers. Five standards that matched an axis recipe but had no computed table (germany_asta_hitze_freien, ukraine_dsn_3_3_6_042_99, denmark_at_indoor_temperature, maharashtra_hap, osha_heat_nep) gained one, bracketing each rule's own published thresholds. A review pass found two live defects that predated the page: "Shade" fed the engine a value it did not recognise — the app's own sunExposure tri-state is ['none','partial','full'], but normalizeTBMEDSunExposure accepted only 'shade'/'fullShade', so 'none' fell through to the "mixed" default and the PARTIAL-sun correction was applied to a worker who had said there is no sun, making Germany ASTA report 36 °C and stop-work where the standard says 34 °C and no stop (TB MED 507 over-reported water intake the same way; both errors sit in the protective direction, which is why they went unnoticed, but a rule that halts work the standard does not halt is still wrong — a genuinely absent value still defaults to "mixed", the documented protective assumption); and midday bans answered "—" out of season because UAE, Oman, Bahrain, Kuwait and Kerala state their outcome in rec.reason and nothing read it. hasPublishedStandardPage() now gates every redirect, wiki card title and hero modal, so the withheld ACGIH id and unknown or retired ids no longer send visitors to a bare 404 — the withheld-id list moved to FORMULAS.UNPUBLISHED_STANDARD_IDS so the browser and the prerenderer read one list.
  • Ten new jurisdiction engines and their registry records. Armenia (Labour Code Art. 153, special breaks above 40 °C and below −10 °C), Azerbaijan (Labour Code Art. 233, stop-work strictly above 41 °C air temperature — "41 dərəcə Selsidən çox" is strictly greater, so exactly 41.0 °C does not trigger — with cooling breaks counted as working time and stoppage paid at not less than two-thirds of the tariff wage, and no graduated table fabricated), Belarus and Uzbekistan (screening_limit; Uzbekistan outdoor permissible 32 °C / harmful above 40 °C, Belarus category ceilings), Moldova and Serbia (screening ceilings; Serbia workplace max 28 °C and outdoor adverse 36 °C, Moldova 32 °C light / 29 °C moderate with a 60 % RH cap), Paraguay (schedule_engine, the ACGIH-derived 12-cell TGBH table adopted by Decreto N° 14.390/92 Art. 228), Switzerland (SUVA four heat stages — level 2 from 21 °C, level 3 from 28 °C, level 4 from 33 °C — with sourceConfidence 'low' because the original checklist was not retrieved), Uruguay (control_overlay; general 17–27 °C and 30–70 % RH bands with task-specific reference bands) and Phoenix (the contractor outdoor-heat ordinance, City Code §18-411, binding on contractors, subcontractors, licensees and lessees on City of Phoenix contracts, licenses or leases, with related Tucson, Pima County and Tempe ordinances noted as separate instruments and other Arizona workers left under federal OSHA/ADOSH general duty). Dispatch, rule-type handling, chart-threshold labels, country mappings, a new scripts/guards/duplicate-tables.mjs and boundary/required-input regression tests ship with them (formulas.js +1,030 lines); the four non-composite rule types (control overlays and screening limits) carry safety-guard notes so an advisory rule cannot enter the work/rest composite.
  • A country-level occupational-heat coverage inventory, and map markers for researched absences. data/country-inventory.json records each of 32 researched jurisdictions' heatStatus — no_rule_found, general_duty_only, rule_identified_not_implemented, rule_implemented, not_researched — and buildGapPins/isGapStatus in map-geo.js turn the confirmed negative findings into a new hollow-ring, amber-core map marker, so a verified absence of numeric rules is distinguishable from an unresearched territory. Amber rather than error-red follows the 2026-09-18 README_DESIGN.md decision that this product's red means cease-work, and the popup names the authority and research date and links the dossier. validate-standards.mjs now fails unless every negative finding is backed by a primary instrument, a dossier and an authority URL.
  • An authority-contact list and a multilingual outreach pipeline, so the regulators whose rules we encode can review how we read them. docs/outreach/authority-contacts.csv covers all 113 registered standards (108 rows: 92 with a direct email, 12 with a contact form, 4 phone-only) with the office, source language, send_language, a confidence grade and a note on what each address actually is; outreach-translate.mjs translates the template once per language via DeepL, falling back to Amazon Translate on the SES credentials for languages DeepL does not target, tokenising and verifying placeholders so a language that loses one writes no cache file; outreach-render.mjs fills the template per row and emits SESv2 SendEmail params as NDJSON, with translated mail bilingual (translation first, then the English original, since the English is what we mean); outreach-send.mjs sends through SES from local .env credentials, dry-run by default with --send and an append-only sent log so a re-run cannot double-mail anyone. test/outreach-render.test.js holds every registered standard covered with a valid address and language, no unfilled merge fields, and translations keeping their placeholders and carrying the English original. Four later rows cover Uzbekistan, Serbia, Moldova and Belarus where no direct email was verified.
  • An offline source-freshness check. Freshness was only checked weekly (source-health.yml) and monthly (source-content-diff.yml), so a lapsed source could sit unnoticed for up to a month and nothing looked at it on a PR; scripts/check-source-freshness-offline.mjs reads the lastChecked timestamps already in data/sources.json and fails past a 90-day threshold, with no network calls because a live-probing PR check would be flaky and get reverted. It is deliberately not wired into CI yet and not tuned to pass: it reported 60/159 sources stale, every one because lastChecked was null rather than aged out, the oldest dated entry being 72 days against the 90-day threshold. test/source-freshness.test.js pins the 90-day constant and uses literal boundary dates, so changing the export cannot keep the gate green while it stops gating.

Fixed - September 18, 2026

  • The coverage ledger was stale and unreachable, and the registry could contradict it without failing. gen-coverage-ledger.mjs initialised row.lastVerified to null and then guarded the populate branch on !== null, so the branch never ran and every lastVerified was permanently null — defeating the file's own purpose of flagging never-verified standards; it now initialises to undefined so the first standard sets the baseline and the existing "null is sticky" comparison works as written. The regenerated ledger had claimed 76 covered jurisdictions and was missing 24 standards outright; it now covers 104 jurisdictions (98 when this fix landed, 104 after the engines added the same week) with engine ids summing to the registry, and a --check mode plus gen-coverage-ledger/check-coverage-ledger npm scripts let it drift-check like every other generated artifact. validate-standards check 8b asserted only no_rule_found and general_duty_only, so a country could claim "rule found, not yet encoded" while a live engine was registered for it — rule_implemented is now a valid status and the contradiction fails; AZ and UZ were the only two affected rows (both have live engines) and are corrected, so the map correctly stops drawing coverage-gap markers for them. The generator also had to be made filesystem-order independent: it walked an unsorted readdirSync, and the committed JSON looked alphabetical only because macOS APFS returns sorted entries while ubuntu-latest runs ext4, so CI would have byte-compared a differently ordered array and failed "stale" on a clean tree; the listing and every emitted array are now sorted.
  • The card no longer attributes one authority's stricter limit to another. getMostProtective takes min(work) and max(rest) independently across every eligible contributor, so contributingStandards[0] — the first standard in registry order — is not the rule the verdict rests on: at WBGT 31.5 / light / US the card read "Binding rule: La Isla Network — 20/40" directly under a stat grid showing La Isla's own 30/30, because the 20/40 came from the US Navy flags. It now reports workBoundBy and restBoundBy, the standards behind each published number, and when the two halves come from different standards the card no longer implies a single authority — it says which rule capped work and which set rest, and that no single standard publishes the pair; the downloadable record and the CALC_COMPLETE event name the same rule the card does. Also in the same review: Paraguay's very-heavy caveat rode only on warnings, which app.js renders only for rows that did not apply, so 60 min/hr continuous work displayed with nothing about ACGIH permitting none — it now travels on recommendation.caveat into the comparison Notes cell and the downloadable record; and the stored-input sanitizer now surfaces rejections in the same banner the permalink path uses, with the raw value, instead of console.warn.
  • Paraguay's very-heavy work is now flagged where the fold is more permissive than modern consensus. Decreto N° 14.390/92 Art. 228 publishes three workload bands (liviana/moderada/pesada) and no metabolic-rate ranges, so computeParaguayDS14390 evaluates very-heavy under pesada — faithful to the decree and kept, because inventing a fourth band would fabricate a threshold the instrument does not contain. But that fold is more permissive at the top: ACGIH TLV permits no continuous and no 75 %-work Very Heavy exposure at any WBGT (both null), while pesada allows continuous work up to 25.0 °C. veryHeavyMappedToHeavy now pushes a warning through the engine's existing warnings path so the gap is stated at the point of use, not only in the source notes. The same change adds the missing Uruguay lower-bound tests — the engine's belowRecommendedTemperature/belowRecommendedHumidity flags had zero test references, so lowering URUGUAY_MTSS_GENERAL_TEMP_MIN_C or _RH_MIN_PCT kept the suite green.
  • A stored out-of-range relative humidity is now rejected rather than clamped. sanitizeStoredCalculatorInputs silently coerced a restored relativeHumidity into 0–100 with Math.max(0, Math.min(100, v)), which AGENTS.md's Sensor Input Validation forbids: an out-of-range value must be surfaced with the raw value preserved, because a clamp substitutes a different worksite condition than the one that was stored — a stored RH of 150 became 100, then 60 on a later fall-through, which is further from the truth and enough to flip Uruguay's engine from out-of-band to in-band silently. The permalink path already rejected-and-reported; the stored path now drops the invalid value so the documented default is used, and warns. In the same app pass, the WBGT input-mode labels ("Single Value" / "Start + Peak" / "Hourly Forecast") were a second vocabulary for the same wbgtInputMode enum the hero already labels Measured / Scenario / Forecast and now use the hero's names; a "Using default values — not your site" badge appears while the WBGT fields are untouched defaults and no location, forecast or meter data has landed (the synthetic 21.7 → 29.4 °C curve could otherwise read as the visitor's own site); on first load, if the Permissions API reports geolocation is already granted, the existing use-my-location handler runs, guarded to run once and never triggering a prompt; and the LIN card renders the composite's binding standard (authority, work/rest, source confidence) as a secondary line when it differs from the LIN card's own numbers.
  • Federal jurisdiction pins carry a country flag, and the guidance map canvas got a little taller. flagEmoji in map-geo.js renders an ISO 3166-1 regional-indicator flag beside Canada, the US and India's pins — via a map-marker flag element on the SVG map and a data-flag pseudo-element on the Mapbox markers — so a national rule anchored at a seat of government rather than the country's centroid is distinguishable from the state-level dots that share those countries; jurisdiction-coordinates.json marks the three as federal.

Changed - September 18, 2026

  • Seven generator drift checks now gate pull requests and the deploy path, and a broken generator can no longer fail silently. Only check-vendor ran on pull_request; check-changelog, check-agent-docs, check-research-index, check-geo, check-events and check-openapi ran in no workflow at all or only on push-to-main, so generated-artifact drift was caught up to a day late by the nightly sync, or not at all — each was run against a clean tree first and added only after it passed, with the new check-coverage-ledger gated too (check-lin-header is excluded on purpose: it is report-only and always exits nonzero by design). The same six were then added to deploy-cloudflare-worker.yml, which duplicates the guards because ci.yml says in its own header that a paths-ignore means none of its guard steps gate main and any guard added to one must be added to the other or it will not protect production. maintenance-sync.yml ran five generators with || true, so a broken one was invisible; the best-effort behaviour is deliberate — one failure must not stop the others or block the commit/PR-open flow for whatever did regenerate cleanly — so the failures are now collected and the step fails at the end.
  • Housekeeping: untracked the transcripts and archives, corrected stale counts and dead docs, and ratcheted the banned-colour list. 27 files (~24 MiB, 95 % of it oldstuff/Archive.zip) were tracked despite .gitignore already listing them — gitignore does not retroactively untrack — and the index is now clean after scanning all of it for credentials (none found: every hit was a variable NAME or a wrangler secret put <NAME> example, so no rotation and no history rewrite); a note records that a sibling transcript once held all six .env values in plaintext. README.md claimed "1555 passing" in one place and "652 passing tests" 950 lines later; both were stale and contradicted each other, and rather than update numbers that go stale on every commit, six hardcoded counts were removed (counts pinned to specific historical commits were left alone). npm start (node scripts/dev.mjs), the actual local dev entry point, was documented for the first time alongside npm run start:api, and the referenced-but-missing .dev.vars.example was added with placeholder values only. vitest.config.mjs's first hazard was marked retired (it described a raw globalThis.window assignment that moved to vi.stubGlobal), environmental-templates.test.js now stubs and unstubs window so a leaked stub cannot collapse STANDARDS_REGISTRY and pass the data-integrity tests against the wrong object, and week-view.js's lone holdout body colour #808285 — the pre-audit value README_DESIGN.md records as failing contrast at 3.85:1 — was restored to #6B7280.

Added - September 17-18, 2026

  • Maps are now Mapbox. Two surfaces, and they use two different Mapbox products on purpose. The Guidance Wiki (now the Standards list at /standards/) renders its jurisdiction map on Mapbox GL JS instead of the custom Robinson-projection SVG map, because it is a reference page read online where a real basemap says more about a jurisdiction than a flat outline; country shading for "no local standard here" is preserved through the mapbox.country-boundaries-v1 tileset, filtered to one worldview so contested borders do not draw as overlapping duplicates. The Today tab keeps the SVG map: it is the offline-capable working surface and its base map is precached. The Site tab header gains a small locator image showing where its worksite is, under the coordinates it already printed. The same locator was built for the Today tab, the Week tab and the WBGT Explorer and then switched off before release — the code is commented out in place, with its hooks, rather than deleted, because re-enabling it is expected. Those are Static Images <img> tags, not GL JS: the view is fixed, so an interactive map would have cost ~1.9 MB of script on the calculator's primary screen and bought nothing. GL JS is lazy-loaded from Mapbox's CDN with a pinned version and SRI on first mount, never at boot, and is not precached — the tiles need the network regardless, so caching the library offline would only buy a map that renders blank. If GL JS or its tiles cannot load, or the style returns 401/403, or nothing has rendered after 15 s, the wiki falls back to the SVG map rather than showing an empty box; the locators simply disappear, since the label beside them already names the place.
  • A detected location is framed at zoom 15.5, close enough to read the block around the worksite. This reverses an earlier decision in the same work, and the constant carries the argument it overruled (mapbox-map.js): the first implementation capped locators at city level on the grounds that a browser geolocation fix is accurate to metres and the guidance that applies is decided by jurisdiction rather than by street. That trade-off was put to the product owner against 15.5 and 15.5 was chosen, so the code says so rather than leaving the next reader to treat it as a default that drifted in. Coordinates are still quantized to 4 decimals (~11 m) before they go into a request URL, which keeps the raw fix out of Mapbox's logs and any referrer header. The wiki map keeps its own, much lower ceiling for an unrelated reason: its pins are jurisdiction centroids, so a street-level view there would show an arbitrary block near a country's geometric centre and imply the rule applies to that block.
  • Mapbox usage is reported on /admin. Mapbox is called directly by the browser, so functions/_upstream-meter.js never sees it and there was no Mapbox number anywhere. Two client events — mapbox_gl_load (one per GL JS map initialization, which is what Mapbox bills as a map load) and mapbox_static_view (one per rendered locator) — land in heatrules_analytics through the existing POST /api/v1/events path, with no new column, no new prop, and no change to the privacy contract in worker/telemetry.js. The panel reports the two products separately against their own 50,000/month free tiers, because pooling them would understate whichever is nearer its ceiling, and a failed query reports "unread" rather than zero.
  • privacy.html now states what Mapbox receives, because the page claimed its table was "the complete set of third-party origins this site loads" and that location coordinates reach only our server and a weather service with the IP withheld. Both became false: the browser fetches each locator image straight from api.mapbox.com, so Mapbox receives the coordinates and the IP without us in the middle, and the wiki's GL map posts usage telemetry to events.mapbox.com. That telemetry is disclosed rather than disabled — in GL JS v3.31.0 EVENTS_URL is a getter, so the usual mapboxgl.config.EVENTS_URL = null opt-out silently does nothing, and the only thing that does suppress it also kills every tile request. Saying it is off when it is not would have been worse than saying it is on.

Added - August 12, 2026

  • Added docs/wbgt-from-weather-api.md: a standalone, non-engineering explanation of the forecast→WBGT chain — inputs and clamps, solar geometry, beam fraction, both Liljegren energy balances with every constant, the fallback ladder, the uncertainty band, and a sourced side-by-side against the NWS/NDFD operational product. Previously this existed only as a 370-line header comment in api/weather-wbgt.js plus README §7.3.

Added - August 21, 2026

  • Surface roughness is now inferred per site from the forecast's own wind profile, instead of assuming open terrain everywhere. The 10 m → 2 m conversion both Liljegren sub-models need is u₂ = u₁₀·ln(2/z₀)/ln(10/z₀), and z₀ was pinned at 0.03 m globally. That over-states 2 m wind over cities and forests, which cools both modelled sensors and under-states WBGT — the unsafe direction, and the one limitation docs/wbgt-from-weather-api.md §6.2 conceded was unquantified. api/weather-wbgt.js now also requests wind_speed_80m; inverting the neutral log law through the two levels (ln z₀ = (ln 10 − r·ln 80)/(1 − r), r = u₁₀/u₈₀) recovers the roughness the weather model itself used, clamped to 0.01–1.5 m. The inversion is declined when the profile is not a neutral log profile (u₈₀ ≤ u₁₀) or when 10 m wind is below 0.5 m/s and both levels are noise, so hours without a usable profile — including the whole NWS fallback path — are bit-identical to before. z₀ is resolved once per hour and threaded down, so the band's wind probe cannot perturb u₁₀ without u₈₀ and launder a roughness error into a reported wind error. Exposed per hour as wbgt.roughnessLengthM / wbgt.windHeightMethod. Over urban roughness the factor falls from 0.72 to ~0.30. Covered by test/wbgt-wind-roughness.test.js, which asserts the direction (rougher ground ⇒ higher, more protective WBGT), not just the arithmetic.
  • Added a second parity grid covering the reference's calc_solar_parameters — 240 points across five sites and eight UT hours, 67 of them where the top-of-atmosphere ceiling binds. vendor/liljegren-wbgt/parity-driver.c gained a solar mode for it. The existing grid passes fdir and cza to the solvers as given, so the reference's own irradiance-ceiling and beam-fraction stage had never been compared against the port at all — which is exactly where the bug below was hiding.
  • Added scripts/validate-wbgt-observations.mjs --recompute: re-runs the reference over the observed weather already in the fixture, so a change to what the reference is fed produces a reviewable diff without needing the network.

Added - August 22, 2026

  • Added an "API Builder" disclosure at the bottom of the WBGT Explorer (wbgt-explorer.js), which emits ready-to-run calls against GET /api/v1/wbgt for a coordinate. Three copyable blocks — a plain curl for the whole JSON estimate, a curl | jq one-liner for the number alone, and a single-cell Google Sheets formula that reads the value live. Latitude and longitude pre-fill from whatever forecast the explorer has loaded (and re-fill when a new place is loaded), falling back to a labelled sample coordinate otherwise. Three things the panel is deliberate about, because each is a place a copied command could mislead someone: it emits no call at all for an out-of-range coordinate rather than clamping onto a nearby valid point (the API answers 400 for those, and a clamped call would return a real, plausible WBGT for somewhere the user never asked about); the samples carry a real User-Agent, since /api/v1/* answers 403 to one under 8 characters; and the °F variants convert explicitly, because the endpoint only ever answers °C. The panel also says plainly that the API returns the location's current-hour forecast WBGT, which is not the same quantity as the slider scene above it — the explorer derives sunlight from a clear-sky model and snaps every input to its slider step, so at near-calm wind the two readings can sit a few degrees apart. Google Sheets has no JSON parser, so the formula fetches the response as text and extracts the first wbgtC value; test/wbgt-explorer-api-builder.test.js evaluates that extraction chain — both regexes and the TEXTJOIN delimiter read back out of the emitted formula — against the real endpoint's response, including the Japanese-worksite case where the MOE block repeats the same key after the ISO estimate, and drives the emitted curl URL through the API's own handler to confirm it is accepted.

Fixed - August 21, 2026

  • Fixed the top-of-atmosphere irradiance ceiling being applied on only one of three beam-resolution paths, so any hour with a provider-reported direct/diffuse split drove both energy balances with uncapped irradiance. The Argonne reference applies normsolar = min(solar/toasolar, 0.85) then solar = normsolar·toasolar inside calc_solar_parameters — unconditionally, before either sub-model runs, and independently of how fdir is obtained. It is a sensor/forecast calibration correction: a reported GHI can exceed what the top of the atmosphere delivers at that zenith angle. In formulas.js the ceiling lived inside liljegrenBeamFraction, reachable only on the derived branch, so the provider-split branch (the normal case for Open-Meteo) skipped it. Extracted as FORMULAS.capSolarToToa and applied on every path. It bites at low sun: at Houston, 5 Aug 2025 20:00, Open-Meteo reported 61 W/m² against a 38 W/m² ceiling. Against Japan's instrumented sites the fix improves every aggregate — delivered bias +1.52 → +1.49 °C, RMSE 2.43 → 2.39 °C, modelled-globe RMSE 4.43 → 4.22 °C — and trades 33 fewer false alarms for one additional missed hour at the ACGIH 28 °C limit (53 → 54 of 1,284). Same-sensor figures are unaffected, so FORMULAS.WBGT_FORECAST_INPUT_ERROR_C stays 1.52 °C.
  • *Fixed scripts/validate-wbgt-observations.mjs reproducing that same defect on its reference side, which is why no test could see it.* solverInputs built the reference's inputs with the identical uncapped provider-split branch, so the harness compared the bug against itself and reported a bias of +0.001 °C over 144 real hours. Both sides now apply the ceiling, and the day-of-year is taken from the row instead of being pinned to 172 for every site on Earth. Reference columns recomputed offline via --recompute; 3 of 144 hours moved, all low-sun, the largest by 1.06 °C.
  • Fixed estimateSolarFromSkyCover keying only on the NWS field name for cloud cover. It read row.skyCover, which only the NWS normalizer sets, while normalizeOpenMeteoHourly sets cloudCover. An Open-Meteo hour missing shortwave_radiation therefore skipped the Kasten–Czeplak model it already had the input for and fell straight to the flat 600 W/m² day / 0 night fallback. Now accepts either.
  • Corrected seven claims in docs/wbgt-from-weather-api.md that the code contradicts (audited in the opposite direction from the August 12 pass — this time the document against the code). Three were flatly wrong: §3.8 claimed the Liljegren constants were "checked constant-by-constant" by the parity test, when that test checks by output over a grid; §3.5 said the globe "needs no artificial wind floor of its own", when the reference floors wind inside h_sphere_in_air exactly as inside h_cylinder_in_air and so do we; and §3.3 said the wind floor "is never applied upward in a way that lowers WBGT", when that is precisely what it does (at 35 °C/50 %/900 W/m², every 10 m wind from 0.00 to 0.18 m/s returns the same 40.8 °C where 0.30 m/s returns 38.9 °C). Also corrected: §2's provider section was a revision behind the code (missing timezone=auto / wind_speed_unit=ms / forecast_days, an invented US-locations gate, a fallback trigger described as an upstream 5xx when every Open-Meteo failure is normalized to 502 first, and direct_normal_irradiance listed as an input when no solver reads it); §3.1 omitted the night-zero cos Z branch; §3.7 over-claimed which ladder rungs pre-round and named four heat-index standards when at least eleven engines read heatIndexF; §5 presented nearCalm as a single 0.70 m/s crossover when it spans 0.55–0.85 m/s; and §6.4 gave the validation window as both "April–July" and "April–September". The same wrong parity claim was mirrored in a formulas.js comment and the same wrong wind-floor claim in a test/wbgt-liljegren-parity.test.js comment; both corrected.
  • Corrected docs/wbgt-japan-validation.md and scripts/validate-wbgt-japan.mjs describing the validated globe as Dimiceli/Piltz. The harness has pushed est.globeTempC — the Liljegren sphere — since the globe model was replaced, so §6.4's +1.22 °C globe figure was correct but attributed to the wrong model. The script header also documented three --mode values it does not implement (all three modes are reported on every run) and repeated the April–September window.

Fixed - August 12, 2026

  • Corrected the attribution of the Liljegren 2008 model in formulas.js, api/weather-wbgt.js and the user-facing about.html, all of which called it "the model the US National Weather Service runs operationally". It is not. Per MDL's own algorithm description (Boyer, NDFD Wet Bulb Globe Temperature Algorithm and Software Design), NWS NDFD/NBM WBGT is built on Dimiceli & Piltz (2013): a linearized black globe whose convective coefficient NWS made variable (0.228 by day, 0 at night, against Dimiceli's published 0.315), and a regression natural wet bulb, currently Tnwb = Tw + 0.001651·S − 0.09555·u + 0.13235·Twd + 0.20249. The correct US federal attribution is OSHA, whose outdoor WBGT calculator estimates "WBGT via the heat and mass transfer algorithm of Liljegren et al. (2008)" and ships the Argonne code (copyright in OTM Section III Ch. 4 App. A). Comments and prose only — no calculation changed — but the claim mattered: it invited benchmarking our output against an NWS grid value that is produced by different physics, and on hot, sunny, low-wind afternoons the two disagree by construction.
  • Corrected the modelled globe diameter described as "150 mm (0.0508 m)" in formulas.js, api/weather-wbgt.js, README.md and about.html — internally contradictory, since 0.0508 m is 50.8 mm. The Argonne reference's D_GLOBE is 2-inch; ISO 7243 specifies a 150 mm globe for the physical instrument. Convective coupling scales as D^-0.5, so the modelled sphere sits closer to air temperature under sun than a 150 mm globe would — a real difference between the model and a WBGT meter, previously hidden by the wrong number. Measured net globe bias against Japan's 150 mm instruments remains positive (+1.22 °C), so this is not currently an under-estimate. References to a 150 mm globe as the instrument (the definition of Tg, Japan's MOE sites, the Argentine standard's sensor spec) are correct and were left unchanged.
  • Fixed the Austria GeoSphere auto-fetch reading the wrong phenomenon, which reported "no heat warning" to every Austrian worksite while real heat warnings were in force — the unsafe direction. api/weather-at-heat-alert.js filtered the GeoSphere warning feed on wtype === 5, but GeoSphere numbers its phenomena 1=wind, 2=regen, 3=schnee, 4=glatteis, 5=gewitter, 6=hitze, 7=kaelte (its own warning application, warnungen.zamg.at/wsapp/js/ws2020.php). The endpoint was therefore matching thunderstorm polygons and ignoring heat entirely. Because a clean 200 with no matching polygon is the one path allowed to assert status:"none", the failure produced an authoritative-looking "no active heat warning" instead of the manual-selector fallback the module's fail-loud contract exists to guarantee. Verified against the live feed on 2026-08-12: Innsbruck, Vienna and Graz all sat inside active wtype=6 heat warnings and all three returned none before the fix, Stufe 2 with the duty engaged after.
  • Fixed the GeoSphere warning level being off by one against the ordinance's own scale, which put a gelb heat warning below the Hitzeschutzverordnung trigger. § 4 Abs. 4 Hitze-V engages the mandatory-measures duty "wenn die GeoSphere Austria eine Hitzewarnung mindestens der Stufe 2 (Vorsicht, gelb) ausweist". The repo instead mapped 1=gelb, 2=orange, 3=rot, 4=extreme, so gelb normalized to level 1 and fell below the engine's >= 2 trigger. GeoSphere's published heat scale is Stufe 1=grün/keine aktive Warnung (<30 °C gefühlte Temperatur), 2=Vorsicht/gelb (≥30 °C), 3=Achtung/orange (≥35 °C), 4=Gefahr/rot (≥40 °C), and the feed's wlevel field is zero-based over that colour scale, so Stufe = wlevel + 1. Corrected in the engine's colour aliases, the API adapter's level mapping, the manual selector, the warning banner, and all standard/source prose. The numeric >= 2 threshold was already correct once the scale is read as Stufe, so no threshold constant moved; what changed is that gelb now resolves to Stufe 2 and engages the duty, as the ordinance requires.
  • Fixed selectHeatWarningForPoint picking the least severe of overlapping heat zones (nivel < bestLevel) while its own docstring promised the most severe. Now selects the highest level, per the most-protective rule.
  • Corrected HGC-099's legal citation: the general Hitzeschutzverordnung (Hitze-V) is BGBl. II Nr. 325/2025 (kundgemacht 29 December 2025, in force 1 January 2026), not BGBl. II Nr. 45/2026 — the latter is the parallel Land- und forstwirtschaftliche Hitzeschutzverordnung (LF-Hitze-V, in force 6 March 2026), which carries the identical Stufe-2 trigger for agriculture and forestry. The registered standard's scope (construction, delivery, security, waste management, festival operations, landscaping) is the general ordinance, so the source record now points at the general instrument and cross-references the agricultural one.

Removed - 2026-07-27

  • Removed the USARIEM Heat Strain Decision Aid engine (usariem_hsda, HGC-070) as unsupported by its own citations (GitHub issue #106). Its load-bearing citation, "USARIEM TR-13-4", does not exist: USARIEM designates technical reports T##-## (T01-11, T08-05, T13-91), no such report is retrievable, and it appears nowhere in the reference list of the other cited source. The in-repo copy of that other source (Potter et al. 2021, Journal of Sport and Human Performance, PDFs/USARIEM_HSDA_Potter_et_al_2021_JHP.md) is a field-validation study containing none of the engine's constants — 70 kg body mass, 3500 J/(kg·°C), 245000 J/°C, and T_start 37.0/37.2 appear nowhere in it, and its cohort mass was 79–84 kg. The physiology actually implemented was the NIOSH REL/RAL curve (56.7 − 11.5·log10 M / 59.9 − 14.1·log10 M), which niosh_curve already covers, so relabeling would have left a duplicate engine with no independent basis.
  • Safety impact of the removal: the engine hardcoded 70 kg body mass with no input, so it credited a 55 kg worker with roughly 27% more permitted minutes than their own heat capacity supports — less protective for anyone below 70 kg. It was registered schedule_engine, making it composite-eligible, so it could set the work/rest verdict and trigger stop-work. Two further defects went with it: t_safe >= 60 reported a bounded safeWorkMinutes while recommending 60/0 indefinitely with no cross-hour accumulation, and the metabolicRateW override path returned stale safeWorkMinutes / predictedNetHeatStorageW alongside a recomputed schedule.
  • Implementing the real HSDA was rejected as out of scope: it takes ~16 inputs through ~32 subroutines (body mass, clo 1.08, im/clo 0.47, solar load, wind, body surface area, hydration state, terrain) that this calculator does not collect. Revisit only if body mass becomes a real input. Removal leaves no coverage gap — 21 other schedule engines produce a work/rest ratio from WBGT plus workload category alone.

Fixed - July 26, 2026

  • Fixed the black-globe model running 6-10 °C cold, which put every sunlit forecast-estimated WBGT 1.3-2.1 °C LOW — the unsafe direction. The Dimiceli/Piltz atmospheric longwave term was computed as eps_atm * Ta^4 with Ta in Celsius; every other term in that numerator is divided by the Stefan-Boltzmann constant and therefore carries units of K⁴, so at 35 °C the term contributed 1.5e6 instead of 9.0e9 and the entire downwelling-longwave input to the globe was effectively deleted. Impact was decision-changing: at 30 °C air / 62 % RH / 6 m/s the corrected chain moves a moderate-workload acclimatized crew from "60 min work per hour, 30/30 composite" to "45/15 and a full stop-work composite".
  • Fixed the natural-wet-bulb port rejecting converged, physically correct solutions via a Tnwb > Ta + 5 °C -> null sanity envelope that is not in the reference implementation. It fired in calm, humid, strongly sunlit air — where a solar-loaded wick genuinely runs 5-6 °C above air — and each rejection fell back to the psychrometric wet bulb, understating WBGT by roughly 4 °C in the highest-hazard hours the tool exists to catch. Replaced with the one physical bound that does hold: the wick cannot cool below the dew point.
  • Fixed the wick iteration starting from Ta - 5 where the reference starts from the dew point. With the reference's 0.9/0.1 relaxation and 0.02 K stopping rule the converged value is start-dependent at the ~0.05 K level, so this was a real divergence from the cited model.
  • Fixed the natural wet bulb being skipped entirely at night and handed to the Stull approximation. Measured against the reference over 80 night grid points, the true natural wet bulb runs up to 2.1 °C above the reference psychrometric value at Ta >= 35 °C; Stull reads ~0.6 °C high against that same baseline, so the old night path was not reliably unsafe but was unpredictable, landing anywhere from 0.8 °C low to 1.0 °C high.
  • Fixed the globe being hard-coded to Tg = Ta for any hour below 10 W/m². A black globe is not equal to air temperature at night: in dry air it loses longwave to the sky and sits up to 5 °C below air, and in hot humid air (where the Brutsaert-form sky emissivity exceeds 1) it sits up to 2 °C above.
  • Fixed ACGIH unacclimatized thresholds being derived by subtracting a flat 3.0 °C from the TLV table. ACGIH publishes a separate Action Limit table and the gap is not constant — 2.5 °C on most light rows, 3.5 °C on heavy — so unacclimatized heavy and very-heavy thresholds sat 0.5 °C ABOVE the published Action Limit, less protective than the standard being cited, for exactly the outdoor-manual-labour population this calculator serves. Also corrected two TLV cells (Heavy 0-25 % 30.0 -> 30.5, Very Heavy 0-25 % 29.0 -> 30.0) to the published values; note these two are the only changes in this release that move a threshold in the less protective direction, and they affect only the most extreme allocation tier, where the regimen is already 15/45 or HALT.
  • Fixed the wick and globe models assuming different direct-beam fractions (0.8 and 0.7) for the same hour. Both now derive it from the reference's own normsolar relation when a provider does not report the direct/diffuse split.

Added - July 26, 2026

  • Added FORMULAS.estimateGlobeTempLiljegrenC, an iterative port of the Argonne reference Tglobe(), as the primary globe model. Chosen over the Dimiceli linearization because it shares one set of constants, one beam fraction and one wind height with the wick model; because Nu = 2 + 0.6Re^0.5Pr^(1/3) keeps the conduction limit and so stays physical as wind approaches zero without an artificial wind floor; and because it is correct at night. Dimiceli remains as a labelled fallback — measured |Liljegren - Dimiceli| on the globe is 4.6 °C at 7 m/s, 5.6 °C at 3 m/s and 12.4 °C at 1 m/s, which is the case for the demotion.
  • Added vendor/liljegren-wbgt/ — the upstream Argonne C source (MIT, via mdljts/wbgt), vendored with pinned SHA-256 hashes — plus scripts/liljegren-parity.mjs, which compiles it and regenerates a 320-point reference fixture. test/wbgt-liljegren-parity.test.js holds the JavaScript port to within 0.08 °C of that fixture at every point. This harness found three of the defects listed above on its first run.
  • Added a per-hour uncertainty band (uncertaintyC, uncertaintyTerms) on every WBGT estimate, computed rather than asserted: the spread between the two independent globe models, the gap the psychrometric fallback would cost if used, and the estimate re-run at ±0.25 m/s wind, summed in quadrature and floored at the 0.05 °C output rounding. Surfaced through /api/v1/weather/wbgt.
  • Added test/wbgt-golden-vectors.test.js (8 named real-world hours, each checked against committed values, physical brackets and the 0.7/0.2/0.1 weights) and test/wbgt-bias-direction.test.js (monotonicity in temperature, humidity, sun and wind; disclosure invariants; and a targeted regression test for the Celsius/kelvin class of bug, asserting the globe responds to sky emissivity).
  • Added scripts/validate-wbgt-observations.mjs and test/wbgt-observation-validation.test.js: a bias gate over 144 real observed hours from six sites across the climates the registry covers (Phoenix, Houston, Chinandega, Delhi, Doha, Niamey). Current bias is +0.003 °C with an RMSE of 0.034 °C and no hour reading materially below the reference. Both this and the parity fixture check now run in CI.
  • Added test/acgih-action-limit.test.js, pinning both published ACGIH tables. Nothing in the suite failed when those tables were corrected, because no test had ever pinned an ACGIH threshold.

Changed - July 26, 2026

  • Changed the near-calm flag from a fixed wind <= 0.75 m/s comparison to the criterion it was a proxy for: an hour is flagged when a ±0.25 m/s wind error — the resolution an hourly forecast cannot beat — moves WBGT by 1 °C or more. It crosses at 0.70 m/s under the current models. The flag still never enters a calculation.
  • Changed the top confidence tier (forecast_estimate_physical_weather) to require solar geometry computed from real coordinates. A physical model run on an assumed sun angle now lands one tier down.
  • Changed callers to be able to supply solarZenithCos and beamFraction directly, rather than having solar geometry silently re-derived or defaulted to a cza = 0.5 stand-in.
  • Rewrote the WBGT sections of about.html and the physics header of api/weather-wbgt.js, both of which documented the replaced model, the old wind floors, and a "matches the full quartic solution to within ~0.3 °C" claim that was not true of the shipped code.

Known gaps

  • The uncertainty band covers model form and wind sensitivity. It does not bound the residual error of the models themselves against a real WBGT instrument: that needs a weather network publishing measured natural-wet-bulb or black-globe temperatures, and neither Open-Meteo nor the Kansas Mesonet REST API exposes one. This is the largest remaining hole in the estimator's rigour and is documented in scripts/validate-wbgt-observations.mjs.

[1.11.0] - 2026-07-01 - Tamil Nadu, Kerala, Finland, And Lithuania Heat Engines

Added - July 1, 2026

  • Added Tamil Nadu Heat Wave Action Plan + BOCW Outdoor-Work Timing Restriction (HGC-085): combines TNSDMA's 2019 IMD-criteria heat-wave advisory overlay (plains absolute-temperature triggers, >=45°C heat wave / >=47°C severe) with the Tamil Nadu Labour Department's binding BOCW construction stop-work window (10am-4pm, Notification No. 2/9958/2023, 9 May 2024). Modeled as two independent components: the BOCW stop-work rule enters the work/rest composite, while the HAP advisory (rest 12:00-15:00, hydration/shelter guidance) is informational only and excluded from it.
  • Added Kerala Seasonal Midday Outdoor-Work Rest Order (HGC-086): the annually reissued Kerala Labour Commissionerate order under the Minimum Wages Act, 1958, ss. 24-25, mandating a 12:00-15:00 rest period for outdoor, sun-exposed workers during the notified hot-season window (illustrative 2026 instance: Feb 13-May 20; the exact dates recur annually and require re-verification against the current year's Government Order).
  • Added Finland Työsuojelu Kuumatyö (Hot Work) Indoor Break Schedule (HGC-087): Työsuojeluhallinto's graduated maximum-continuous-work-period schedule triggered by indoor dry-bulb air temperature — 28-33°C caps continuous work at 50 min/hour (10-minute break), above 33°C caps at 45 min/hour (15-minute break). Classified guidance rather than regulation because the authority's claim that the figures were confirmed by the Supreme Administrative Court (KHO) could not be independently verified against a case citation.
  • Added Lithuania HN 69:2003 Indoor Temperature Ceilings + VDI Outdoor Heat-Break Guidance (HGC-088): HN 69:2003's warm-season indoor ceilings (28°C for light/office work, 26°C for physically demanding work) paired with VDI's outdoor break-frequency duty (breaks no less often than every 1.5 hours, minimum 40 minutes aggregate per 8-hour shift, once outdoor air temperature exceeds 28°C). The two branches evaluate independently depending on which inputs are supplied. A graduated reduced-working-hours table attributed to a single untraceable secondary source was deliberately not encoded, as it conflicts with the verified VDI ceiling framing.

[1.10.1] - 2026-06-30 - API Layer Hardening: AQI Accuracy, Input Validation, Weather Timezone Fixes, And Country Filtering

Fixed - 2026-06-30

  • Fixed /api/v1/weather/air-quality presenting Open-Meteo's composite multi-pollutant us_aqi (a max across PM2.5/PM10/O3/NO2/SO2/CO) as if it were the PM2.5-specific AQI; the displayed AQI and the value feeding California's AQI-canonical §5141.1 wildfire-smoke engine are now always derived from measured PM2.5 via the EPA 2024 breakpoint formula (FORMULAS.pm25ToAqi), the endpoint now selects the correct forecast hour by converting to the location's local hour instead of comparing against server UTC, and app.js no longer has a fallback path that could substitute the composite AQI for the PM2.5-specific value when live PM2.5 data was unavailable.
  • Fixed the heat-guidance API (api/heat-api.js) accepting physically impossible sensor inputs — e.g. a -999°C WBGT reading previously returned a confident "safe, continuous work" recommendation. Added range validation (rejected outright, never silently clamped) for WBGT, relative humidity, air/indoor/max/wet-bulb/globe/mean-radiant temperature, wind speed, metabolic rate, atmospheric pressure, thermal work limit, heat index, and PM2.5/AQI; calendar-invalid dates like 2026-13-45 (which JS's date auto-rollover let slip past the old digit-only regex) are now rejected, and hourly WBGT rows with an empty-string value are now rejected at the API boundary instead of being silently dropped later.
  • Fixed three defects in the weather-based WBGT lookup (api/weather-wbgt.js): omitting time/shiftStart no longer picks the wrong forecast hour for non-UTC locations (the current hour is now derived from the location's own UTC offset rather than the server's UTC wall clock); the hourlyWbgt array is now correctly narrowed to the shiftStart/shiftEnd window when both are supplied; and the documented provider query parameter (auto/open-meteo/nws) is now actually honored instead of being silently ignored.
  • Fixed the Cloudflare Pages Functions weather-forecast cache serving stale KV entries as if they were fresh — a KV cache hit is now checked against a 30-minute freshness window before being served directly, with entries older than that falling through to a refresh from the upstream API. Also added a top-level error boundary to the /api/v1/* request pipeline (functions/_middleware.js) so an uncaught exception in a route handler now returns the app's standard structured {success:false, error, detail} JSON response instead of a raw runtime error.
  • Fixed POST /api/v1/guidance/country so it actually filters results by country: standards with no meaningful relationship to the target country/region (internal relevance priority 7) are now excluded, while exact-country, regional-bloc, text-hint, and global standards (NIOSH, ISO, ACGIH, etc.) remain visible; requests with no resolvable country code are unaffected and still return the full unfiltered list. Also added the missing POST /api/v1/guidance/country route to the local Express server (server.js), which previously only existed in the Cloudflare Pages Functions deployment.

[1.10.0] - 2026-06-30 - EU Standards Wave, San Antonio Ordinance, And Interactive Jurisdiction Map

Added - June 30-July 1, 2026

  • Added six EU Tier-1 occupational heat engines (HGC-079-084): Slovenia (mandatory 15-min break every 2-3h above 30°C outdoor / 28°C indoor cap), Malta (construction ≥40°C triggers mandatory supervisor reassessment), Romania (>37°C or asserted THI>80 triggers Tier 1 measures), Sweden (WBGT ceiling table by workload × acclimatization, stop-work above ceiling), Poland (preventive drinks above 25°C outdoor / 28°C workstation / WBGT 25°C), and Latvia (warm-season indoor caps 28/27/26°C by category). Each ships with a standard JSON, registry entry, dispatch handler, named threshold constants, and a country_guidance.md row; research dossiers for all 19 EU countries scanned are kept under docs/research/.
  • Added the San Antonio contractor outdoor-heat ordinance (HGC-078): city-funded contractors must provide a 15-minute rest break every 4 hours once heat index reaches 95°F, plus a heat-relief station and a submitted heat-safety plan. Modeled as a control_overlay (excluded from the work/rest composite) and deliberately scoped to city contracts to avoid Texas HB 2127 state preemption of local heat rules; a new US-TX region pill surfaces it in the hero chart for Texas users.
  • Added an interactive black-and-white world map (GuidanceMap, MapToolbar, MapModal) above the Framework Comparison table, pinning every jurisdiction that has a registered heat or smoke standard. Clicking a pin lists every standard at that location with links to its comparison-table row and source. Global standards (NIOSH, ISO, ACGIH, La Isla, etc.) are listed separately as "applies everywhere" instead of being mis-pinned to one country. Includes filter chips, search-to-fly, a "you are here" auto-focus highlight, coverage-gap shading for un-covered countries, and a fullscreen expand view. Backed by map-geo.js (pure, unit-tested grouping/filter helpers) and data/jurisdiction-coordinates.json, with a validate-standards.mjs + data-integrity guard that every pinnable jurisdiction has a coordinate.
  • Added country-aware time formatting (time-format.js) so displayed times follow the detected country's locale conventions instead of always rendering US-style.
  • Added a documentation scan of outdoor-worker heat-rule status for the 15 largest US cities (USA city guidance status.md), used to scope the San Antonio ordinance and confirm NYC/Phoenix/San Antonio are currently the only cities with enforceable local rules.

Changed - June 30-July 1, 2026

  • Folded the Air Quality result into the same summary row as Shift peak WBGT / Work time / Rest time / Peak rule, so AQI only appears as an additional card in that row when air-quality data is actually available, rather than as a separate standalone card.
  • The version chip now also shows the current git branch, so a build can be identified by branch as well as version/commit.
  • "Expand map" is now a bordered button with a hover state, and the expanded map view is a true full-screen overlay rather than a centered modal card.

Fixed - June 30-July 1, 2026

  • Fixed missing map coordinates for the six new EU standards (Latvia, Malta, Poland, Romania, Slovenia, Sweden), which had failed the CI data-integrity check after they were added.
  • Hardened jurisdiction-pin resolution: ISO 3166-2 sub-national codes (containing a hyphen) now must resolve to their own coordinate — a missing one is surfaced as a missing entry instead of silently collapsing onto the country-level pin. Pseudo-regions (EU/GCC) still intentionally fall through to the country pin.
  • Fixed the 6 new EU engines flagged by automated PR review: Slovenia and Malta no longer default a missing outdoor/construction-scope flag to the more permissive branch; Romania's engine never actually sets stopWork, so it was reclassified from stop_work_rule to control_overlay; Poland no longer advertises a drink/meal duty on the no-trigger path; Latvia now requires an explicit workload input instead of defaulting to the least-restrictive tier.

[1.9.0] - 2026-06-25 - NYC Heat Order, Source-Accuracy Audit, And Threshold Lanes Chart

Added - June 25, 2026

  • Added NYC Executive Order No. 17 outdoor-heat guidance engine (HGC-077). Modeled faithfully as a control_overlay (no work/rest schedule, excluded from the composite): it reinforces baseline outdoor controls (water, shade/cooled rest, breaks, bathroom access, acclimatization, training, symptom reporting) and ties heat-illness-prevention plans to City Heat Emergency activation (heat index ≥100°F single-day or ≥95°F for two consecutive days). A follow-up pass reclassified it from guidance to regulation after rereading the order: it has a specific enforceable effect for food-delivery workers (mandatory restaurant bathroom access under NYC Administrative Code §20-563), split out via a new regulatoryScope.mandatoryFor/advisoryFor distinction so the engine doesn't overstate or understate who it binds.
  • Added a region-scoped guidance pill (US-NY) so NYC EO 17 appears in the hero chart's threshold row only for users detected in New York, via a new REGION_STD_SETS mechanism that extends the existing country-scoped pill logic without changing what non-NY US users see.
  • Added the Threshold Lanes chart (chart-lanes.js, ThresholdLanesChart): a horizontal-lane overview of every applicable standard's trigger point, grouped by metric (WBGT, heat index, air temperature, PM2.5 smoke), with a "you are here" marker per lane showing which standards current conditions have already crossed.
  • Added country-scoped filtering (filterCountryScopedResults / COUNTRY_SCOPED_ENGINE_IDS) so region-specific rules (e.g. Qatar) only display for users detected in the relevant region (e.g. GCC), instead of appearing for every user worldwide.
  • Consolidated the primary recommendation UI: LinGuidanceCard now folds the La Isla RSH-s primary recommendation, AKI kidney-risk panel, productivity-impact panel, and hydration panel into one "shift stats" card, replacing the separate PrimaryRecommendation / KidneyRiskPanel / ProductivityPanel / HydrationPanel components (now unused).

Fixed - June 25, 2026

  • Fixed 12 dead or incorrect source URLs across heat standards found by a full-registry source-verification audit (TB MED 507, ISO 7933, Maryland MOSH, Nevada R053-20, Costa Rica, Belgium, US Navy BUMED, Ontario OHCOW, Gujarat HAP, Saudi MHRSD, Egypt Decree 211, China GBZ 2). No threshold or logic changes.
  • Corrected verified threshold/data errors found in the same audit: Thailand MoL WBGT limits (32/30/28 → 34/32/30°C, per Phanprasit et al. 2020), Chile DS 594 (removed a non-existent 4th "very heavy" tier), Costa Rica DE 39147 (corrected Level III/IV boundaries and hydration guidance), Bahrain's midday work ban window (July 1-Aug 31 → June 15-Sep 15, per a 2025 Ministry of Labour decision), and Washington outdoor heat (removed a 77°F double-layer-woven action level dropped from the final permanent rule). Also corrected several standard/authority naming and attribution errors (Nevada's superseded-vs-adopted rule number, Bangladesh's cited schedule, AAFA's metric, NFL overlay's true origin as the Georgia High School Association chart, Minnesota's tier coverage).
  • Fixed z-index layering of the Threshold Lanes chart's markers and connector lines, and a missing chart-lanes.js script include that had left the new chart non-functional after the initial build/dev-server changes.

[1.8.0] - 2026-06-23 - Standards Metadata Surfacing, Registry Hardening, And Data-Accuracy Fixes

Added - June 22-23, 2026

  • Added sourceIds and parametersLastUpdated to every standard result and surfaced them in the UI: the comparison-table row now shows an "updated YYYY-MM-DD" meta line, and the standard detail card shows a "Parameters last updated" field. Backed by a new data/changelog.json (generated from git history via scripts/gen-changelog.mjs) and exported through data/standards-loader.js.
  • Added legalStatus/ruleType enum validation to scripts/validate-standards.mjs, catching typos or unrecognized values before they reach the UI.
  • Gated validate-standards and the changelog-freshness check into npm test (test/data-integrity.test.js), so an inconsistent registry or a stale changelog now fails the suite instead of only being caught manually.
  • Added CI test enforcement on pull requests and a local pre-push hook that blocks pushes to main when tests fail.
  • Grew the test suite significantly: dispatch-path coverage for 14 previously-untested engines (558→606 tests), direct compute-function tests for 9 regulatory engines including the wildfire-smoke tier boundaries (606→650), and data-integrity checks for the full 50-state-plus-D.C. inventory (650→652). The engine-registry minimum-count floor was also raised from 69 to 76 to catch accidental engine deletions.

Changed - June 23, 2026

  • Bumped esbuild, vitest, and wrangler to pick up security fixes, including a path-traversal fix in esbuild's dev server and a form-data CRLF-injection fix; wrangler was bumped again shortly after via Dependabot.
  • Replaced the in-repo Home Assistant custom integration with a link to a dedicated external repository, homeassistant-wbgt, which calls the /api/v1/weather/wbgt endpoint. The integration had briefly been added directly into this repo (calling /api/v1/compare) but was removed in favor of the standalone repo so it can be a proper HACS-installable package rather than living inside this monorepo.

Fixed - June 22-23, 2026

  • Fixed the OSHA federal heat rulemaking status, which was still described as an active proposed rule. The NPRM's public comment period closed January 14, 2025, and the subsequent regulatory freeze paused finalization indefinitely; formulas.js, the standard JSON, country_guidance.md, and the README were all updated in lockstep to note OSHA's National Emphasis Program as the current federal enforcement vehicle.
  • Updated stale Virginia and New Mexico heat-rule status notes (Virginia's VOSH must adopt final regulations by 2028-05-01 under enabling legislation; New Mexico's proposed rule hearing has been rescheduled multiple times and is not yet adopted) so the state-inventory data doesn't imply either state has a rule in effect.
  • Fixed a missing regionCode: ["US-CA"] on the Cal/OSHA Indoor Heat standard — the only California-scoped engine that lacked it, inconsistent with the other CA engines.
  • Fixed an air-quality test that depended on the current UTC hour, intermittently selecting the wrong mocked AQI value and flipping the expected severity.
  • Fixed stale enum values and a stale test count in the "How To Add Another Standard" guide, whose template JSON referenced invalid legalStatus/ruleType values that no longer validate.

[1.7.3] - 2026-06-11 - Washington Wildfire-Smoke Trigger-Message Fix

Fixed - June 11, 2026

  • Fixed computeWashingtonWildfireSmoke reporting the generic ≥20.5 µg/m³ trigger message for readings in the [35.5, 500.4) µg/m³ exposure-controls tier instead of the tier-specific "≥35.5 µg/m³ mandatory exposure controls + voluntary N95" message, matching the pattern already used by the Oregon engine. Added boundary tests at 35.5 µg/m³ and tightened the APF≥25 test to the exact 555.0 µg/m³ boundary (was previously tested at 600).

[1.7.2] - 2026-06-11 - Calculation-Accuracy Audit

A standards-grounded audit of the calculation layer. Every numeric finding was verified against a primary source (NWS/WPC, ACGIH, NIOSH 2016-106, the cited TB MED 507 PDF, EPA 40 CFR Part 58 App. G, Liljegren et al. 2008, Dimiceli et al., WA L&I) before any change. Several flagged "issues" were verified to be CORRECT as written and left unchanged (see Verified-correct below).

Fixed - June 11, 2026

  • Outdoor WBGT natural wet bulb. The outdoor WBGT 0.7 term used the psychrometric

wet bulb (provider value or Stull 2011) directly as the natural wet bulb (Tnwb). Under solar load and low wind Tnwb runs ~0.8-2°C higher, so this under-estimated WBGT (unsafe direction) — enough to drop a heat category in calm, sunny conditions. Added FORMULAS.estimateNaturalWetBulbLiljegrenC, a faithful port of the Liljegren et al. 2008 energy-balance model (NWS operational standard; Argonne reference code), used for the 0.7 term whenever solar geometry allows, with a psychrometric fallback at night / when inputs are missing. Validated: night ≈ psychrometric, +2-3°C midday sun, +~5°C calm desert sun.

  • NWS heat index. estimateHeatIndexF now follows the full NWS algorithm: switch

to the Rothfusz regression on the computed simple heat index (not raw temperature), and apply the NWS low-RH (RH<13%, 80-112°F) and high-RH (RH>85%, 80-87°F) adjustments, both previously omitted.

  • Washington wildfire-smoke rule. Updated from the superseded emergency rule

(WAC 296-62-085, mandatory at 555.5 µg/m³) to the permanent WAC 296-820: mandatory full respiratory protection program at 500.4 µg/m³, APF ≥ 25 at 555.0 µg/m³, with the 20.5 / 35.5 µg/m³ lower tiers. Workers in 500.4-555.4 µg/m³ were previously under-protected.

  • AAFA table coverage gap. Closed a 0.1°C gap at [27.7, 27.8) that fell through

to the most-severe default band.

  • La Isla RSH-s note. The +3°C unacclimatized adjustment is the acclimatization

offset, not a clothing (CAF) factor; fixed the mislabeled note.

Verified correct (no change) - June 11, 2026

  • Dimiceli black-globe model. The Celsius t^4 terms are intentional: the constants

256000 = 4·40³ and 7,680,000 = 3·40⁴ are the exact Taylor linearization of t⁴ about 40°C, confirming the model is formulated in Celsius. A reported "Stefan-Boltzmann Kelvin bug" was a false positive; converting to Kelvin would have broken validated math.

  • TB MED 507 Table 3-2. Matches the cited primary PDF exactly (all 5 categories,

4 work columns at 250/425/600/800 W, fluid caps 1.5 qt/hr & 12 qt/day, ±¼ qt sun/shade modifier). A subagent's "broken table" claim used an outdated 3-column reproduction.

  • EPA 2024 PM2.5↔AQI, ACGIH TLV table, NIOSH REL/RAL (M in Watts),

metabolic categories, unit conversions, WBGT coefficients, and acclimatization handling (effectiveC = wbgtC + cafC, acclimatization applied once) all verified correct.

Notes

  • The flat +3°C unacclimatized offset is a documented approximation (true ACGIH AL−TLV

range is 2.5-3.5°C); the +1.5°C "partial" tier has no direct published basis. Left as disclosed approximations.

[1.7.1] - 2026-06-11 - Live Forecast Date And Shift-Time Accounting Fixes

Fixed - June 11, 2026

  • Fixed the live forecast using a stale date. The forecast/legal-trigger date was persisted to localStorage and restored for up to 30 days, so a value saved on an earlier day (e.g. May 22) drove the live-forecast fetch to the wrong day and displayed an out-of-date WBGT as if current. sanitizeStoredCalculatorInputs now drops any persisted date, so the calculator always starts on today. This was the root cause of the large WBGT discrepancy versus live WBGT tools (e.g. 53 °F shown for a cool past day vs. ~84 °F for the actual day) — the WBGT engine itself was correct.
  • Fixed the date default being computed in UTC (new Date().toISOString()), which rolls to the next calendar day on US evenings and would request the wrong forecast day. Added todayLocalDate(), which builds YYYY-MM-DD from local date components.
  • Fixed the hero "Work time" card showing more hours than the shift length (e.g. "9h of 8.5h shift"). shiftStats counted every hourly sample whose integer hour fell within the shift as a full 60 minutes and used inclusive bounds on both ends. It now weights each sample by the fraction of its hour block that overlaps the shift, so work + rest can no longer exceed the shift duration.

Notes

  • The unused WBGTForecastChart component (defined but never rendered) contains the same whole-hour shift-accounting pattern. Left untouched because it is dead code and out of scope for this fix.

[1.7.0] - 2026-06-05 - State Rules Panel, AQI↔PM2.5 Conversion, Respirator Badge, Legend

Added - June 5, 2026

  • Added StateRulesPanel — replaces WildfireSmokePanel; generalizes it to include both control_overlay (heat) and smoke_overlay (smoke) engines for the active US state. Shows jurisdiction header ("California — enforceable workplace rules"), per-engine employer-duty list, legalStatus badge, trigger threshold, and respirator tier.
  • Added neutral OSHA/NIOSH coverage line for recognized US states with no engine (e.g., TX). Driven from resolveStateEngines fields (oshaCoverage, heatStatus, note). Uses --info (#9CBECF), never --heat-danger. No fabricated numeric guidance for unserviced states.
  • Added FORMULAS.pm25ToAqi(pm25) and FORMULAS.aqiToPm25(aqi) — EPA piecewise-linear AQI formula with 2024-revised PM2.5 24-hour breakpoints (89 FR 16202 / aqs.epa.gov). PM2.5-specific AQI only.
  • Wired cross-conversion into the results useMemo: CA gets aqiPm25 (manual AQI → converted from PM2.5 → live AQI); OR/WA get pm25ugm3 (manual/live PM2.5 → converted from AQI). CA smoke guidance now computes from PM2.5-only input.
  • Added manual AQI-for-PM2.5 input field beside the PM2.5 field (Conditions & Modifiers).
  • Added respirator chip on AirQualityCard: when an applicable smoke engine is active, the hero card shows the respirator requirement badge beside the AQI value.
  • Added GuidanceLegend — collapsible glossary above the comparison table. Explains "regulation" vs "guidance" and the three respirator tiers (voluntary / FFR required / full RPP) in worker/supervisor language.
  • Promoted the US state selector out of Conditions & Modifiers into the shift/date inputs grid, since it determines which laws apply (not a modifier of physical conditions).
  • Extended test/us-state-guidance.test.js with 27 new tests: EPA 2024 breakpoint boundary values, pm25ToAqi / aqiToPm25 inverses, round-trip stability, CA smoke engine cross-input correctness, and resolveStateEngines coverage-line fields for TX and NY.

Changed - June 5, 2026

  • activeStateCode is now a prop on ResultsPanel and threaded to StateRulesPanel.
  • activeSmokeResult is derived at the main component and passed into WBGTHeroSection → AirQualityCard.

[1.6.0] - 2026-06-04 - U.S. State Heat And Wildfire-Smoke Guidance

Added - June 4, 2026

  • Added six U.S. state-scoped engines (HGC-071…HGC-076): Cal/OSHA outdoor heat (Title 8 §3395), Colorado agricultural heat (7 CCR 1103-15 Rule 3), Nevada heat program (R053-20), and wildfire-smoke / respiratory-protection rules for California (§5141.1), Oregon (OAR 437-002-1081), and Washington (WAC 296-62-085). Each adds a data/standards/*.json file, registry + rule-type entries, a dispatch handler, a pure compute function with named threshold constants, and source footnotes.
  • Added a new smoke_overlay rule type for the wildfire-smoke engines. Smoke overlays are excluded from the work/rest composite (getMostProtective), so air-quality guidance never alters the heat schedule math.
  • Added data/us-state-inventory.json: the regulatory status of all 50 states plus D.C. (OSHA coverage, heat status, AQI status, applicable engine ids). Loaded in Node via standards-loader.js and injected into the browser bundle by scripts/dev.mjs / scripts/build.mjs.
  • Added FORMULAS.resolveStateEngines(), FORMULAS.logUnservicedState(), and FORMULAS.STATE_SCOPED_ENGINE_IDS. Recognized states with no engine yet are logged to the console and render nothing — a deliberate hook for future use.
  • Added a U.S. state selector, an industry selector (drives Cal/OSHA high-heat and Colorado agriculture scope), and a manual PM2.5 field to the calculator inputs; the live Open-Meteo PM2.5/AQI feed is wired into the wildfire-smoke engines.
  • Added a WildfireSmokePanel to the results view showing respirator tier, trigger threshold, controls, and source for the active state's wildfire-smoke rule.
  • Extended /api/v1/geo/country to return Cloudflare regionCode/region, enabling U.S. state auto-detection (with a manual override).
  • Added test/us-state-guidance.test.js covering engine thresholds, state scoping, composite isolation, the 50-state inventory, helper behavior, and data-source integrity.

Notes

  • State heat engines (CA outdoor, CO agriculture, NV) are temperature/heat-index based, not WBGT, so they appear in the standards comparison table rather than the WBGT hero-chart threshold pills.

[1.5.0] - 2026-06-04 - Dynamic Chart Thresholds, Country-Aware Standards, And USARIEM HSDA

Added - June 4, 2026

  • Added chart-thresholds.js, a standalone module containing WBGT_CHART_STANDARDS (24 entries), COUNTRY_STD_SETS, getLocationStandards, computeChartThreshold, deriveChartStandards, and getDisplayStandards. The browser bundle no longer embeds these constants inline (2891a50).
  • Added dynamic WBGT threshold computation for la_isla, ACGIH TLV, NIOSH REL/RAL, ISO 7243, Vietnam QCVN, China GBZ, Singapore MOM, Thailand TIS, India BIS, Japan, South Korea KOSHA, Qatar, South Africa PAR 2024, Mexico NOM-015, Brazil NR-15, El Salvador D89, Nicaragua RM, and Colombia Res 2400. Thresholds in the hero chart now shift when workload, acclimatization, or clothing changes (87805e1).
  • Added country-aware guidance threshold pills: the hero chart auto-selects the set of standards most relevant to the user's detected country (COUNTRY_STD_SETS covers 40+ country codes) and seeds enabledStdIds on first load (3ba1de7).
  • Expanded WBGT_CHART_STANDARDS from 10 to 24 entries, adding Japan WBGT, South Korea KOSHA, South Africa PAR 2024, Australia Safe Work, Mexico NOM-015, Brazil NR-15, El Salvador D89, Nicaragua RM, Colombia Res 2400, Costa Rica DE, Italy INAIL, Germany ASR A3.5, France Décret 2025-482, Spain RD 486, Greece heatwave decree, and UK HSE guidance. Standards with no fixed WBGT threshold (framework-based or heat-index-based) are now shown as non-toggleable pills with an em-dash value (3ba1de7).
  • Added USARIEM Heat Strain Decision Aid (HSDA) integration: the decision aid physiological model is wired into the comparison engine and documented in the standards reference (3caa49c, 40f53bb).
  • Added a Heat Threat Scheduler standalone HTML design prototype for shift-planning and scheduling UI exploration (a619f6d).

Changed - June 4, 2026

  • Redesigned the primary inputs section with numbered step badges (① WBGT, ② Work Intensity, ③ Sun Exposure), a SegmentedControl component replacing the workload and sun exposure <select> dropdowns, and a teal-bordered "Set Conditions" card that visually separates primary inputs from secondary conditions (3ba1de7).
  • Sun exposure field promoted from the Conditions & Modifiers panel into the primary inputs card, making all three core parameters visible without scrolling (3ba1de7).
  • Geo-country fetch now always fires on mount (previously gated on !hasStoredUnit()); detectedCountry state is set regardless, enabling the country-aware pill strip while the unit auto-select logic remains unchanged (3ba1de7).

Fixed - June 4, 2026

  • Fixed ACGIH computeChartThreshold for very-heavy workload: VeryHeavy.continuous and VeryHeavy.w75 are both null in ACGIH_TLV, causing the function to return null and silently bypass acclOffset and clothing CAF adjustments. Now falls back to w50 (28.0°C), the highest work-share tier with a defined ACGIH limit, so adjustments are applied correctly (3ba1de7).

1.4.1 2026-05-22

  • Added 14 pending country, state, provincial, and sector engines, growing the registry from 55 to at least 69 standards. This work is still uncommitted, so no commit hash exists yet.
  • Added HGC-056 washington_osha_outdoor: Washington DOSH outdoor heat thresholds by clothing type, including 80 F standard-clothing action level, lower double-layer/vapor-barrier thresholds, 90 F and 100 F high-heat break schedules, one-quart-per-hour hydration guidance, and acclimatization observation notes.
  • Added HGC-057 oregon_osha_heat: Oregon OSHA heat-index tiers, paid shade-rest schedules from 90 F through 105 F, one-quart-per-hour hydration, and PPE escalation when double-layer or impermeable clothing increases heat burden.
  • Added HGC-058 maryland_osha_heat: Maryland MOSH indoor/outdoor heat-index trigger logic at 80 F, 90 F, and 100 F, with mandatory cool-down breaks, shaded or cooled rest areas, and one-quart-per-hour water guidance.
  • Added HGC-059 minnesota_osha_indoor: Minnesota indoor WBGT limits by workload, including light/moderate/heavy thresholds and 30/30 work-rest recommendations when limits are exceeded.
  • Added HGC-060 bc_worksafebc_heat: WorkSafeBC heat exposure guidance that delegates to ACGIH TLV/AL screening while adding BC-specific Exposure Control Plan, monitoring, hydration, and seven-day unacclimatized-worker notes.
  • Added HGC-061 chile_ds594_heat: Chile DS 594 TGBH/WBGT work-rest tables for light, moderate, heavy, and very-heavy work, including 60/0, 45/15, 30/30, 15/45, and stop-work outcomes.
  • Added HGC-062 belgium_bien_etre: Belgium WBGT action ceilings by workload, with mandatory ventilation, refreshing drinks, and administrative controls when ceilings are exceeded.
  • Added HGC-063 us_navy_ashore_flags: US Navy and Marine Corps ashore flag conditions, mapping WBGT to White/Green/Yellow/Red/Black flags with acclimatization restrictions and body-armor/vapor-barrier considerations.
  • Added HGC-064 costa_rica_de_39147: Costa Rica four-level heat-index model, heavy-work/PPE risk escalation, Level IV stop-work handling, hydration notes, and ERCnt prevention context.
  • Added HGC-065 cyprus_heat_decree: Cyprus dry-bulb temperature and relative-humidity stop-work matrix by workload, plus orange/red heat warning midday suspension notes.
  • Added HGC-066 austria_hitze_scheg: Austria construction-sector Hitzefrei rule at 32.5 C shade temperature, modeled as a financial/operational stop-work option rather than a universal outdoor-work ban.
  • Added HGC-067 ontario_ohcow_humidex: Ontario OHCOW adjusted Humidex schedule, including 15/45, 30/30, 45/15, and stop-work bands, PPE/acclimatization adjustments, and 240 mL every 20 minutes hydration guidance.
  • Added HGC-068 nicaragua_rm_higiene: Nicaragua TGBH/WBGT work-rest tables and clothing penalties for cotton robes, winter uniforms, and impermeable wet-weather gear.
  • Added HGC-069 colombia_res_2400: Colombia Resolucion 2400 WBGT-aligned compliance checks, periodic rest-pause guidance, continuous fluid supply, and industrial-hygiene survey notes.

Changed - pending registry plumbing

  • Expanded data/standards-index.json, data/sources.json, data/standard-details.json, formulas.js, test/engine-shape.test.js, and test/formulas.test.js for the pending 14-engine expansion.
  • Added formula-level tests for the new engines, including boundary checks for Washington, Oregon, Maryland, Costa Rica, Minnesota, Chile, Belgium, Nicaragua, Colombia, US Navy flags, Cyprus, Austria, and Ontario.
  • country_guidance.md is currently staged separately from the implementation changes. Convert this section into a numbered release after the staged docs and unstaged implementation receive a real commit hash.

[1.4.0] - 2026-05-22 - WBGT Forecast Surface, Water Mode, And Operational Polish

Added - May 21, 2026

  • Added a full-width interactive WBGT hero forecast surface, replacing the older CalendarTimeline experience with a chart-first workflow for inspecting forecast heat risk over time (3867d3d).
  • Added richer hourly forecast rows for both Open-Meteo and NWS fallback data. The forecast pipeline now carries date/time, temperature, relative humidity, cloud cover or sky cover, solar radiation, and WBGT instead of only time/value pairs (3867d3d).
  • Added Open-Meteo cloud_cover support and expanded forecast coverage to seven days, making the hero chart useful for shift planning beyond the current day (3867d3d).
  • Added interactive chart controls to the hero: sliders, steppers, scenario chips, draggable handles, a legend, and real-data handling for forecast-backed WBGT review (3867d3d).
  • Added WBGT calculator design assets, prototype source files, screenshots, Inter fonts, token CSS, and chart variants to document and iterate on the forecast UI direction (a701c68).
  • Added an effective-WBGT PPE overlay in the hero. When clothing has a non-zero CAF, the chart shows an orange dashed effective-thermal-load line above the raw WBGT curve (e64cdfc).
  • Added auto-ranging Y-axis behavior with 15 percent padding and an eight-degree minimum span, so cool or mild forecast periods zoom into meaningful changes instead of being trapped in a fixed 18-38 C range (e64cdfc).
  • Added draggable top and bottom Y-axis handles plus reset controls, allowing reviewers to manually expand or restore the visible WBGT range while inspecting forecast bands (e64cdfc).
  • Added TB MED 507 Table 3-2 water-intake estimates and a hero Water mode, tying hydration estimates to effective WBGT, workload, shift length, and sun/shade exposure (9ebee8f).
  • Added extracted TB MED 507 source material to support the water-mode and work/rest corrections (1fe79d9).

Changed - May 21-22, 2026

  • Changed hero work/rest bars, colors, peak callout, and summary stat cards to use effective WBGT (raw WBGT + CAF) when PPE changes the thermal burden (e64cdfc).
  • Documented the expanded product surface in README.md, including accepted inputs, unit persistence, Scenario Quick-Select, weather-derived WBGT, all 55 standards, comparison behavior, output panels, API routes, and architecture diagrams (e64cdfc).
  • Corrected TB MED 507 Table 3-2 work/rest and hydration values, including no-limit easy-work rows and very-heavy workload rows, with formula tests covering the corrections (9ebee8f).
  • Improved Cloudflare Pages deployment configuration and Mermaid docs rendering (163dcf0).
  • Added Dependabot coverage for the npm ecosystem (f2e363c).

[1.3.0] - 2026-05-20 - Registry Hardening, Stable IDs, And Unit Localization

Added - May 20, 2026

  • Added stable HGC-001 through HGC-055 identifiers across every registered standard. UIDs now appear in per-standard JSON, inline registry metadata, comparison results, service API output, and the country-guidance table (5bcfa91).
  • Added the Cloudflare geo-country endpoint and unit-aware Scenario Quick-Select flow. First-run users can default to F/C based on country while saved user preferences remain authoritative (f5b94d3).
  • Added baseline snapshot capture tooling and fixture data to protect the large standards refactor from accidental behavioral drift (3d9eccc).
  • Added an esbuild live-reload development server exposed through npm run start (55a4cc5).

Changed - May 11-20, 2026

  • Extracted source footnotes and standard details from formula code into JSON-backed registries, making source notes and details easier to audit and update (f8bc2d4).
  • Split STANDARDS_REGISTRY into per-standard JSON files and added validation scripts so new standards can be added through data files instead of editing one monolithic registry (4fbb862).
  • Replaced the large _runEngine switch with a generated dispatch map, reducing coupling between the registry and individual standard engines (ef15e8f).
  • Added DRY helpers, an engine-shape contract test, data patch generation, and build/dev validation hooks for the standards registry (82b762e, 8e8aa72).
  • Rewrote the "How To Add A Standard" guide around the data-driven registry workflow (91ffc9d).
  • Converted the calculator UI from broad prop drilling to useReducer, then followed with accessibility fixes for the main landmark, form labels, and WBGT slider descriptors (af2ff14, 7108366).
  • Fixed cal_osha_indoor metadata so implemented optional inputs such as high radiant heat and heat index are documented in the registry (5bcfa91).

[1.2.0] - 2026-05-07 - Symphony Workflow, Scenario UX, And Country Guidance Services

Added - May 4-7, 2026

  • Added a country-personalized heat-guidance endpoint backed by countryCode, regionCodes, sorting logic, and a Cloudflare Pages Function, allowing country-specific recommendations to be requested without running the full comparison UI (6becd4b).
  • Added environmental templates and scenario-selection UI enhancements, including named presets and richer starting points for common heat-exposure conditions (de8f9a1).
  • Added Symphony workflow management, including YAML workflow parsing, template rendering, Codex client integration, Linear client integration, HTTP server support, logging, workspace management, and tests (7e8798b).
  • Added direct repository mode for Symphony with stash/restore behavior, path validation, local review-comment posting, and tests for no-push handoff workflows (459725b).
  • Added Claude Code as a fallback agent backend, with configuration options, CLI/workflow wiring, docs, and fallback tests (49cb51d).

Changed - May 7, 2026

  • Consolidated active architecture, roadmap, and direct Linear/Symphony/git workflow guidance into current docs while removing stale plan-only documents (aae1e5c).
  • Enhanced South Africa heat regulation logic with WBGT action levels, occupational exposure limit handling, country-guidance notes, and tests (2021c82).
  • Updated stop-work messaging to show readable standard names rather than raw standard IDs (4d1f6ba).
  • Resolved Linear todo UI issues and improved issue-review ergonomics in the calculator surface (2116f4d).

[1.1.0] - 2026-05-03 - Outdoor Heat Standards Expansion

Added - May 3, 2026

  • Added 22 outdoor heat stress standards and overlays in one large standards expansion, with formulas, source notes, API exposure, tests, and docs updated together (ca48a79).
  • Added programmable country engines for China GBZ 2.2, Thailand MoL, Mexico NOM-015, Egypt Decree 211, South Africa PAR 2024, Taiwan Article 303-1 heat-index overlay, South Korea KOSHA WBGT, and Indonesia Permenaker 5 (ca48a79).
  • Added v1.0-cycle country guidance for Germany ASR A3.5, Australia Safe Work, UK HSE, Spain RD 486/1997, France Decret 2025-482, EU Framework 89/391, Kenya OSHA 2007, Turkey OSH 6331, Taiwan high-temperature work, South Korea MoEL heat, Italy Worklimate, and Greece ministry heatwave guidance (ca48a79).
  • Added sports heat-guidance coverage and source PDFs for NFL and MLB workflows (ca48a79).

Changed - May 3, 2026

  • Removed Pakistan and Nigeria awareness overlays when they did not meet the threshold for confident programmable guidance (69f62c9).
  • Updated dependencies during the expansion, including React 19 and express-rate-limit v8 compatibility, and adjusted rate-limit header expectations (ca48a79).

[1.0.0] - 2026-05-01 - API, Weather, And Deployment Foundation

Added - April 29, 2026

  • Added the first Heat Guidance API surface with a Node/Express server, heat-guidance-service.js, payload validation, health check, standards listing, standards comparison, single-standard evaluation, Supertest coverage, and package scripts (82ca3c0).
  • Added API call generation in the UI and initial API rate limiting support in the service layer and server (0b95098).
  • Refactored API code into api/heat-api.js, added Cloudflare Pages Functions for standards listing, standard evaluation, comparison, and health checks, and introduced shared JSON/method utility helpers (e567eff).
  • Added build tooling and Wrangler/Pages configuration so the static app and API functions can be built for Cloudflare Pages deployment (e567eff).
  • Added source PDFs and extracted markdown for Gulf-region and international heat guidance, including Bahrain, Brunei, Gulf Sustain, ILO heat at work, Oman, Saudi Arabia, and UAE materials (f5ebb2b).
  • Added GitHub Actions deployment for Cloudflare Pages, secrets verification, and production-branch configuration (3c7705d, 6d13a39, 8fd666a).

Added - April 30-May 1, 2026

  • Added Open-Meteo weather WBGT integration, query validation, hourly weather normalization, WBGT estimation utilities, weather API tests, and the /api/v1/weather/wbgt Pages Function (69a0ff5).
  • Added NWS fallback behavior and stronger weather API error handling for weather-derived WBGT requests (02481c4).
  • Added the /api/v1/wbgt weather-derived WBGT endpoint and Dimiceli/Piltz-related calculations for estimating heat exposure from forecast conditions (722235f).
  • Added caching and rate-limit middleware, including Cloudflare Cache API handling, cache keys, in-memory throttling, endpoint integration, cache tests, and rate-limit documentation (bdf7a4d).
  • Added a rate-limit validation shell script and expanded examples in the rate-limiting docs (db65bef).

Changed - April 29-May 1, 2026

  • Updated country_guidance.md with new standards and more operational notes during the API/registry expansion (24edcc4).
  • Added frontend, backend, researcher, and orchestrator agent descriptions to guide future project work (18c5b2e).

[0.4.0] - 2026-04-28 - Calculator Maturation And Standards Logic

Added - April 28, 2026

  • Added multi-tier weather fetching architecture with validation and fallback planning for Open-Meteo, Visual Crossing, Tomorrow.io, and NWS data sources (2db89ff).
  • Added temperature conversion, improved scheduling suggestions, live input support, and broader UI handling for common calculator workflows (ba5dae7).
  • Added richer standard title, flag, source-link, and detail-card UI styling, plus checks that public source URLs and display metadata are available for standards (42c9fe1).
  • Added El Salvador Decreto 89 calculations and tests for TGBH and workload classification (6cfcc9e).
  • Enhanced Gujarat HAP thresholds and ISO 7933 PHS tests for accuracy (16f0e5b).

Changed - April 28, 2026

  • Reality-checked formulas and docs to reduce over-claiming, especially where standards required conservative interpretation or stronger source traceability (eef4126).
  • Moved legacy prototype files into oldstuff/ as the project shifted from static prototype toward tested calculator and API surfaces (2db89ff).
  • Updated development planning and formula implementations as standards logic became more concrete and test-backed (c02f846).

[0.2.0] - 2026-04-27 - Testable Prototype And Research Groundwork

Added - April 27, 2026

  • Added comprehensive fixtures and unit tests for WBGT calculations, acclimatization, country-specific guidance, ACGIH TLV/AL, TB MED 507, NIOSH REL/RAL, La Isla RSH-s, India BIS, AAFA, Vietnam QCVN, Singapore, Qatar, UAE, Japan, hydration, productivity, calendar generation, standards comparison, boundaries, registry integrity, and red-team checks (773d8f2).
  • Added a large initial calculator implementation across app.js, formulas.js, styles.css, index.html, test fixtures, and package setup, moving the app from research artifacts toward a runnable tested calculator (773d8f2).
  • Added a comprehensive improvement plan and extracted source markdown for worker heat-stress references, including AAFA, Bangladesh, Brazil, Gujarat, Vietnam, Peerless, WRC, and related source material (dcac4df).

[0.1.0] - 2026-04-27 - Initial Import

Added - April 27, 2026

  • Imported the starting Heat Guidance Calculator prototype, source PDFs, country-guidance seed document, development plan, static WBGT calculator files, and local server script (42e9941).
  • Added the Enhanced Heat Stress Calculator implementation plan and initial country-guidance expansion notes (ce45e1a).