diff --git a/tools/storm_envelope/envelope.html b/tools/storm_envelope/envelope.html
new file mode 100644
index 0000000..3b62ea3
--- /dev/null
+++ b/tools/storm_envelope/envelope.html
@@ -0,0 +1,140 @@
+
+
+
+
storm_envelope
+
+storm_envelope — the wind side of the failure envelope
+loading…
+
+
+
+
diff --git a/tools/storm_envelope/envelope.js b/tools/storm_envelope/envelope.js
new file mode 100644
index 0000000..a6424fe
--- /dev/null
+++ b/tools/storm_envelope/envelope.js
@@ -0,0 +1,107 @@
+/**
+ * envelope.js — the STORM-side failure envelope, per anchor. [Lane C, SPRINT12]
+ *
+ * SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the
+ * cloth sim (site_audit: settle, fly the storm, read peak corner loads in N).
+ * This is the SECOND harness on the same envelope, measured from the other side:
+ * no cloth, no rig, no tension — just what the STORM delivers at each anchor's
+ * position, through the same wind everyone samples (createWind + the site's
+ * venturi + the tree shelters, exactly as main.js wires them at site load).
+ *
+ * Two harnesses, one number — the repo rule. What must agree:
+ * · ORDERING: anchors ranked by wind-side pressure/hint should rank the same
+ * way B's peak corner loads do, once quad geometry is accounted for. An
+ * anchor whose load outranks its wind exposure has a variable to find.
+ * · TIMING: B's peak corner load should land near the wind-side tPeak (cloth
+ * lag is under a second; the venturi's alignment window is tens of seconds).
+ * What will NOT agree, by construction: absolute newtons. Corner load carries
+ * quad geometry, cloth porosity, tension and rain mass — all B's side. This
+ * harness deliberately knows none of it, which is what makes it independent.
+ *
+ * Per anchor, per storm:
+ * peak highest local horizontal wind speed over the storm, m/s
+ * (wind.speedAt — the anemometer number, same units as every probe
+ * table in THREADS)
+ * tPeak when it happened, s
+ * peakPa peak dynamic pressure, Pa: ½ρ·v²·(1+downFrac²) — the (1+d²) folds
+ * the downdraft back in, since cloth feels the full vector while
+ * speedAt deliberately reads horizontal-only
+ * dose ∫ v² dt over the whole storm, m²/s — exposure × time, so a long
+ * grind and a single spike stop looking alike
+ * paPerHint peakPa / ratingHint — the wind-side "who blows first" ranking.
+ * B's wiring is `load > hw.rating * ratingHint`, so dividing the
+ * delivered pressure by the hint predicts failure ORDER for equal
+ * hardware and equal geometry. Prediction, not measurement — B's
+ * harness is the one that owns the geometry.
+ *
+ * Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = i·dt over
+ * the storm's duration, same seed path (createWind(def) — def.seed), so the two
+ * harnesses fly the SAME storm, not merely the same JSON.
+ */
+
+import { createWind } from '../../web/world/js/weather.js';
+import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
+
+export const ENVELOPE = {
+ RHO: 1.225, // kg/m³, sea-level air — the constant, named once
+ DT: FIXED_DT,
+};
+
+/**
+ * Measure the storm at every anchor.
+ *
+ * @param {object} o
+ * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? }
+ * — pass the DRESSED world.anchors (browser) or a
+ * verified dump; graybox positions measure a yard
+ * that does not ship (site_audit's lesson).
+ * @param {object} o.stormDef parsed storm JSON
+ * @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) —
+ * a sweep without it flies an easier yard than ships
+ * @param {Array} [o.probes] extra { id, pos } points (bed centre, throat…)
+ * measured alongside, hint fixed at 1
+ * @param {number} [o.dt]
+ * @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending
+ */
+export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) {
+ const wind = createWind(stormDef);
+ wind.setVenturi(venturi);
+ // exactly main.js:447 — every tree anchor casts a shadow, defaults and all
+ wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
+ const downFrac = wind.core.downFrac;
+ const vecFold = 1 + downFrac * downFrac; // |v|² = h²·(1+d²) when vy = -d·h
+
+ const rows = [
+ ...anchors.map((a) => ({
+ id: a.id, type: a.type, probe: false,
+ hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1,
+ pos: { x: a.pos.x, z: a.pos.z },
+ peak: 0, tPeak: 0, dose: 0,
+ })),
+ ...probes.map((p) => ({
+ id: p.id, type: 'probe', probe: true, hint: 1,
+ pos: { x: p.pos.x, z: p.pos.z },
+ peak: 0, tPeak: 0, dose: 0,
+ })),
+ ];
+
+ const duration = stormDef.duration ?? 90;
+ const n = Math.round(duration / dt);
+ for (let i = 0; i <= n; i++) {
+ const t = i * dt;
+ for (const r of rows) {
+ const s = wind.speedAt(r.pos, t);
+ if (s > r.peak) { r.peak = s; r.tPeak = t; }
+ r.dose += s * s * dt;
+ }
+ }
+
+ for (const r of rows) {
+ r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold;
+ r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1);
+ // what B's wiring makes each shop tier hold HERE: rating × hint, in N
+ r.effN = HARDWARE.map((h) => h.rating * r.hint);
+ }
+ rows.sort((a, b) => b.paPerHint - a.paPerHint);
+ return { rows, downFrac, duration };
+}
diff --git a/tools/storm_envelope/envelope.selftest.js b/tools/storm_envelope/envelope.selftest.js
new file mode 100644
index 0000000..28922e4
--- /dev/null
+++ b/tools/storm_envelope/envelope.selftest.js
@@ -0,0 +1,100 @@
+/**
+ * envelope.selftest.js — the second harness proves it measures. [Lane C, SPRINT12]
+ *
+ * Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
+ * selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
+ * flew the corner block with the funnel switched off for two sprints; this file
+ * exists so the harness built to CHECK that tool can't quietly do the same.
+ * Every assert here is written to fail if a wiring line is deleted:
+ * · drop setVenturi → the throat anchor's funnelled/unfunnelled peaks
+ * collapse to equal and the strict < goes red
+ * · drop setSheltersFromTrees → the sheltered anchor stops reading lower
+ * · reseed per call → the determinism byte-compare goes red
+ * · flip paPerHint to × → the weak-anchor-ranks-first assert goes red
+ */
+
+import { stormEnvelope } from './envelope.js';
+
+const TESTS = [];
+const test = (name, fn) => TESTS.push([name, fn]);
+const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
+
+/**
+ * A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
+ * gusts, no wander, no spatial noise — so every local effect is the ONLY thing
+ * moving a number, and the asserts can be strict instead of statistical.
+ */
+const FLAT = {
+ name: 'flat_test', seed: 7, duration: 20,
+ baseCurve: [[0, 10], [20, 10]],
+ dirCurve: [[0, 0], [20, 0]],
+ dirWander: { amp: 0, rate: 0 },
+ spatial: { amp: 0 },
+ gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
+};
+
+const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
+
+test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => {
+ const anchors = [A('a', 0, 0), A('b', 5, 3)];
+ const def = JSON.parse(JSON.stringify(FLAT));
+ const r1 = stormEnvelope({ anchors, stormDef: def });
+ const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
+ for (let i = 0; i < r1.rows.length; i++) {
+ assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
+ && r1.rows[i].tPeak === r2.rows[i].tPeak,
+ `run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
+ }
+});
+
+test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => {
+ const anchors = [A('throat', 0, 0), A('far', 100, 0)];
+ const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
+ const off = stormEnvelope({ anchors, stormDef: FLAT });
+ const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
+ const g = (res, id) => res.rows.find((r) => r.id === id);
+ // strict: delete envelope.js's setVenturi call and this collapses to equal
+ assert(g(on, 'throat').peak > g(off, 'throat').peak,
+ `funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`);
+ assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
+ `gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
+ assert(g(on, 'far').peak === g(off, 'far').peak,
+ 'the funnel reached an anchor 100 m away — radial falloff is broken');
+});
+
+test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => {
+ // tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
+ const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
+ const without = [A('lee', 4, 0)];
+ const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
+ const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
+ // strict: delete envelope.js's setSheltersFromTrees call and these read equal
+ assert(sheltered.peak < open.peak,
+ `the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`);
+});
+
+test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
+ // identical position, identical wind — only the hint differs (E's beam 0.22
+ // vs a clean post at 1.0). The wind-side prediction must rank the beam first.
+ const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
+ const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
+ assert(rows[0].id === 'beam',
+ `equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
+ assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
+ 'paPerHint is not peakPa/hint — the ranking formula drifted');
+ assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
+ `effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
+});
+
+test('envelope: peaks land inside the storm and doses are finite and positive', () => {
+ const anchors = [A('a', -3, 1), A('b', 6, -2)];
+ const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
+ for (const r of rows) {
+ assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
+ assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
+ // flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted
+ assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`);
+ }
+});
+
+export const ENVELOPE_TESTS = TESTS;
diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js
index 03e7140..d9bd456 100644
--- a/web/world/js/tests/c.test.js
+++ b/web/world/js/tests/c.test.js
@@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';
+import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
@@ -41,6 +42,11 @@ export default async function run(t) {
const { cases } = weatherCases(storms);
for (const c of cases) t.test(c.name, c.fn);
+ // --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
+ // Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
+ // B's audit must not be the thing that drifts. See envelope.selftest.js.
+ for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
+
// --- the bits that need the THREE adapter, not just the core ---
t.test('weather.js satisfies the wind contract', () => {