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.
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.
/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.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.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.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.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.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.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).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_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.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).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.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.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.#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.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./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.--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./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.<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.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.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.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.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.[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.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.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.--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.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.#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.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.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.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.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.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.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.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.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.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.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.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./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.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./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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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".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.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.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.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.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.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.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).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.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.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.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.solarZenithCos and beamFraction directly, rather than having solar geometry silently re-derived or defaulted to a cza = 0.5 stand-in.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.scripts/validate-wbgt-observations.mjs.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./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.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.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./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.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.country_guidance.md row; research dossiers for all 19 EU countries scanned are kept under docs/research/.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.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.time-format.js) so displayed times follow the detected country's locale conventions instead of always rendering US-style.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.missing entry instead of silently collapsing onto the country-level pin. Pseudo-regions (EU/GCC) still intentionally fall through to the country pin.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.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.REGION_STD_SETS mechanism that extends the existing country-scoped pill logic without changing what non-NY US users see.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.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.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).chart-lanes.js script include that had left the new chart non-functional after the initial build/dev-server changes.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.legalStatus/ruleType enum validation to scripts/validate-standards.mjs, catching typos or unrecognized values before they reach the UI.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.main when tests fail.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.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.regionCode: ["US-CA"] on the Cal/OSHA Indoor Heat standard — the only California-scoped engine that lacked it, inconsistent with the other CA engines.legalStatus/ruleType values that no longer validate.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).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).
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.
estimateHeatIndexF now follows the full NWS algorithm: switchto 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.
(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.
to the most-severe default band.
offset, not a clothing (CAF) factor; fixed the mislabeled note.
t^4 terms are intentional: the constants256000 = 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.
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.
metabolic categories, unit conversions, WBGT coefficients, and acclimatization handling (effectiveC = wbgtC + cafC, acclimatization applied once) all verified correct.
range is 2.5-3.5°C); the +1.5°C "partial" tier has no direct published basis. Left as disclosed approximations.
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.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.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.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.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.resolveStateEngines fields (oshaCoverage, heatStatus, note). Uses --info (#9CBECF), never --heat-danger. No fabricated numeric guidance for unserviced states.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.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.AirQualityCard: when an applicable smoke engine is active, the hero card shows the respirator requirement badge beside the AQI value.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.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.activeStateCode is now a prop on ResultsPanel and threaded to StateRulesPanel.activeSmokeResult is derived at the main component and passed into WBGTHeroSection → AirQualityCard.data/standards/*.json file, registry + rule-type entries, a dispatch handler, a pure compute function with named threshold constants, and source footnotes.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.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.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.WildfireSmokePanel to the results view showing respirator tier, trigger threshold, controls, and source for the active state's wildfire-smoke rule./api/v1/geo/country to return Cloudflare regionCode/region, enabling U.S. state auto-detection (with a manual override).test/us-state-guidance.test.js covering engine thresholds, state scoping, composite isolation, the 50-state inventory, helper behavior, and data-source integrity.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).87805e1).COUNTRY_STD_SETS covers 40+ country codes) and seeds enabledStdIds on first load (3ba1de7).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).3caa49c, 40f53bb).a619f6d).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).3ba1de7).!hasStoredUnit()); detectedCountry state is set regardless, enabling the country-aware pill strip while the unit auto-select logic remains unchanged (3ba1de7).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).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.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.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.minnesota_osha_indoor: Minnesota indoor WBGT limits by workload, including light/moderate/heavy thresholds and 30/30 work-rest recommendations when limits are exceeded.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.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.belgium_bien_etre: Belgium WBGT action ceilings by workload, with mandatory ventilation, refreshing drinks, and administrative controls when ceilings are exceeded.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.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.cyprus_heat_decree: Cyprus dry-bulb temperature and relative-humidity stop-work matrix by workload, plus orange/red heat warning midday suspension notes.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.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.nicaragua_rm_higiene: Nicaragua TGBH/WBGT work-rest tables and clothing penalties for cotton robes, winter uniforms, and impermeable wet-weather gear.colombia_res_2400: Colombia Resolucion 2400 WBGT-aligned compliance checks, periodic rest-pause guidance, continuous fluid supply, and industrial-hygiene survey notes.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.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.CalendarTimeline experience with a chart-first workflow for inspecting forecast heat risk over time (3867d3d).3867d3d).cloud_cover support and expanded forecast coverage to seven days, making the hero chart useful for shift planning beyond the current day (3867d3d).3867d3d).a701c68).e64cdfc).e64cdfc).e64cdfc).Water mode, tying hydration estimates to effective WBGT, workload, shift length, and sun/shade exposure (9ebee8f).1fe79d9).raw WBGT + CAF) when PPE changes the thermal burden (e64cdfc).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).9ebee8f).163dcf0).f2e363c).5bcfa91).f5b94d3).3d9eccc).npm run start (55a4cc5).f8bc2d4).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)._runEngine switch with a generated dispatch map, reducing coupling between the registry and individual standard engines (ef15e8f).82b762e, 8e8aa72).91ffc9d).useReducer, then followed with accessibility fixes for the main landmark, form labels, and WBGT slider descriptors (af2ff14, 7108366).cal_osha_indoor metadata so implemented optional inputs such as high radiant heat and heat index are documented in the registry (5bcfa91).countryCode, regionCodes, sorting logic, and a Cloudflare Pages Function, allowing country-specific recommendations to be requested without running the full comparison UI (6becd4b).de8f9a1).7e8798b).459725b).49cb51d).aae1e5c).2021c82).4d1f6ba).2116f4d).ca48a79).ca48a79).ca48a79).ca48a79).69f62c9).ca48a79).heat-guidance-service.js, payload validation, health check, standards listing, standards comparison, single-standard evaluation, Supertest coverage, and package scripts (82ca3c0).0b95098).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).e567eff).f5ebb2b).3c7705d, 6d13a39, 8fd666a)./api/v1/weather/wbgt Pages Function (69a0ff5).02481c4)./api/v1/wbgt weather-derived WBGT endpoint and Dimiceli/Piltz-related calculations for estimating heat exposure from forecast conditions (722235f).bdf7a4d).db65bef).country_guidance.md with new standards and more operational notes during the API/registry expansion (24edcc4).18c5b2e).2db89ff).ba5dae7).42c9fe1).6cfcc9e).16f0e5b).eef4126).oldstuff/ as the project shifted from static prototype toward tested calculator and API surfaces (2db89ff).c02f846).773d8f2).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).dcac4df).42e9941).ce45e1a).