Skip to content

v1.12.0 — Hold together 🛡️ (roadmaps 25-27, the hardening batch) - #212

Merged
AlexZ005 merged 32 commits into
mainfrom
release/next
Sep 17, 2026
Merged

AlexZ005 merged 32 commits into
mainfrom
release/next

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Roadmaps 25, 26 and 27 — the hardening batch, as v1.12.0 "Hold together". Five lane PRs already merged to release/next, each green on build / check / unit / e2e-smoke:

PR Wave What
#206 1 diagnostics, signaling reconnect, runtime loops, the wire dispatcher, approval timeouts and session caps, CI + the ratchet, script safety, GPU disposal and context loss
#208 2a storage: IndexedDB that always settles, autosave that measures itself, safeStorage, the mic given back
#209 2b the overload gateway: one poke per frame, a time-sliced ingest queue, a windowed object list, the scene budget and Statistics panel, the ingest gate, the auto-stops
#210 3a one session clock and sessionNow(), and joinresult
#211 3b the scene-stress rig, four-peer net-stress, and the adaptive quality governor

The batch answers the 2026-09-05 audit: its only CRITICAL (a runaway script node freezing every peer — 27-D), five Highs, and the two phases earlier passes never started.

What is worth knowing at review

  • svelte-check went 362 → 341 / 47, and the floor is now DATA. check-baseline.json at the repo root is read only by scripts/check-ratchet.cjs, which release.yml and ci.yml both call; the hardcoded number in release.yml (stale at 362 while the tree measured 359) is gone. This PR also fixes RELEASING.md, which still told the next releaser to edit that number.
  • CI on pull requests exists now (27-I): build / check / unit required, e2e-smoke reporting-not-gating while SwiftShader runs at ~4.5fps.
  • The CHANGELOG entries for wave 1 were misfiled under the shipped 1.11.0 heading — the lane wrote them into ## Unreleased while branched off 1.10.0, and the 1.11.0 bump renamed that heading around them. c8258dd moves them into the new 1.12.0 section.
  • Measured, not estimated. Roadmap 26's budget table was guesses; Hardening wave 3b — roadmap 26: the stress rig, four-peer net-stress, and a governor that drops shadows before frames #211's rig replaced it with numbers from a real GPU, and in doing so found that the budget meter's triangles and draw calls had been counting a single fullscreen pass (renderer.info auto-resets per render(), and a desktop frame is 13 of them). 1,000 boxes read triangles: 1.
  • Several defects were found in this batch's own new code by covering it, not after shipping: an approval timer that cleared the stamp it had just written, a session cap counted off the dial-time whitelist, a releaseMic that left the toolbar claiming an open mic, and a joiner recording its trigger epoch before the first clock pong.

Still open after this

  • Wave 4 — 26-F import decimation. Deliberately held until the rigs had measured; they have now, so it is startable.
  • A latent bug with its own ticket: autosave's GLTFExporter.parse passes no options, so onlyVisible defaults TRUE and a visible = false object is silently dropped from the recovery snapshot.
  • Two pre-existing two-peer reds, A/B'd and reproducible: multi-select "member transforms replicated to B" and undo "the placed position replicates".
  • Owed on device: a genuinely full disk, real Safari private mode, whether push-to-talk still feels instant, the OS recording indicator by eye, and the VR panel's reject/at-cap answers in a headset.

🤖 Generated with Claude Code

AlexZ005 and others added 30 commits September 12, 2026 05:46
…e bundle

- src/lib/diagnostics.js, a zero-store LEAF (svelte/store + version.js only): a
  300-entry ring, log(level, scope, message, data), the lastUncaught store, the
  registerDiagnosticsSection seam, bundle/bundleText/copyDiagnostics, and
  startDiagnostics installing window error + unhandledrejection capture.
- App.svelte installs it FIRST in onMount and registers the session section (peer id,
  open conns, roster, object and mesh counts, renderer.info). The seam is what keeps
  diagnostics.js free of store imports, so the modules sitting inside the documented
  import cycles can log without closing one.
- Toasts.svelte mirrors lastUncaught into ONE sticky card with Copy diagnostics (the
  restoreAvailable idiom); Settings About gains a Diagnostics row.
- Recovery paths report through log(warn, ...) now: autosave 5 sites, flowRuntime 3,
  moduleSDK 8. peerHandler's are deferred to 27-A, which rewrites that file.
- Nothing leaves the browser: the bundle goes to the clipboard and nowhere else.

Why: the audit's H4. src/lib held 135 console.log against 17 console.error/warn and
there was no window.onerror or unhandledrejection handler anywhere, so an uncaught
error in a store subscriber broke that subscriber chain silently and a user had no
way to hand over what happened.

Suite tests/e2e/diagnostics.test.cjs, 17 checks, ALL PASS.
Counterfactuals, each broken then restored (file verified byte-identical after):
- window error listener removed: the three error-capture checks go red.
- section try/catch removed: the run ABORTS on section-boom, which is the point. A
  bundle that cannot be produced when something is broken is worthless.
- CAP raised 300 to 400: the ring holds 320 and keeps line 0.

Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to the base
measured in this worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
…rebuilt

- netBackoff gains jitter with an injectable rng, and an unbounded max. Both are
  ADDITIVE and inert by default, so every existing caller is byte-identical.
  backoffSchedule takes a limit, because an unbounded max has no full schedule and the
  loop never terminated - measured as RangeError: Invalid array length beforehand.
- peerHandler now has ONE recreate ritual. Three callers each kept their own copy of
  destroy, createPeerForMode, attachVoiceToPeer, wire (the public fallback, the runtime
  switchServer, the id-collision retry) and the fourth - a peer whose link CLOSED - did
  not exist at all. That is why a closed peer stayed dead: reconnect() cannot revive a
  spent Peer object, so the only way out was a reload.
- The retry is unbounded: 800ms doubling to an 8s ceiling, plus or minus 25 percent so a
  room of tabs does not return in lockstep. It used to stop after five attempts and tell
  the user to reload, which drops every live DataConnection AND the invite id, while the
  thing that failed is usually a lid closing or a wifi hop. The CAPPED interval protects
  the server; the attempt count protected nobody.
- online and visibilitychange retry immediately and reset the schedule: the wait exists
  for a server that is down, not for a link that has just come back.
- unavailable-id met on a REBUILD now schedules another rebuild rather than falling
  through to "please reload" - the same dead end, by another door.
- One toast on the way in, one on recovery, and a chip on the Connect pill for the live
  state. An unbounded retry that toasts per attempt is spam.

Suites: signaling-reconnect NEW, 17 checks, ALL PASS. net-backoff 8 to 15 checks (jitter
at three injected rng values, the clamp that stops a negative wait, unbounded saturation
at the cap, schedule termination). connect-states green, which is the pill's data-state
contract.

Counterfactuals, each broken then restored, peerHandler verified byte-identical after:
- bounded retry restored: the attempt stalls at 5 and the reload toast returns, 3 red.
- close handler reverted to log-only: the rebuild and reopen checks, 2 red.
- the two retry listeners removed: retry-now and schedule-reset, 2 red.

Two defects this phase's own suite found in it, both fixed here: the rebuild is guarded
on open, which a synthetic close leaves true, so two neighbouring checks had been passing
vacuously against the object they were meant to replace; and the id-collision branch only
ever covered the first open.

net-reconnect's "B's new object reaches A after the heal" is PRE-EXISTING, not from this
phase: the same single check fails with these five files reverted to 2faa46b.

Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to the base
measured in this worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
…ession

- flowRuntime: `tick` re-arms the frame in a `finally`. It used to call runTick and THEN
  requestAnimationFrame, so an exception escaped before the re-arm and no further frame
  was ever scheduled: every flow animation and every physics step stopped for the rest of
  the session, silently. One bad module task, post-tick hook or node evaluator was enough.
- safeRunTick is shared by the desktop scheduler and the XR pump, so a headset gets the
  same guard rather than a second copy of the bug.
- A tick that keeps throwing PAUSES after 120 consecutive failures, with a Resume card,
  instead of burning a core at the frame rate with nobody watching. flowPaused lives in
  flowStore because 27-D's safe-mode boot sets it before the runtime starts.
- Per-frame failures are rate-limited PER KIND, first three then one per 300. A throwing
  frame task writes 60 lines a second otherwise, which evicts the context around the
  first failure - the only line that says what broke.
- physics: the step is wrapped, so a throw (a NaN off the wire, a poisoned body, a rapier
  panic inside wasm) stops the simulation ONCE with a toast and leaves the scene intact.
  It used to escape into the post-tick slot and be logged forever with the sim dead.
- Two TEST-ONLY hooks, failTicksForTest and throwOnNextStepForTest, because every real
  path into those bodies is individually caught - which IS this phase - so the threshold
  and the re-arm would otherwise be unprovable.

Suite tests/e2e/runtime-resilience.test.cjs, 15 checks, ALL PASS.
Counterfactuals, each broken then restored (both files verified byte-identical after):
- runTick called unguarded: the loop dies. Spin 0 to 0, frame task 0 calls, and the pump
  throw escapes as SCRIPT FAILED. That is the historical bug, reproduced.
- physics wrapper removed: both physics-stop checks go red.
- rate limiter removed: 7 log lines for 5 calls instead of at most 4.

Held: flow-runtime ALL PASS.
UNRESOLVED, and deliberately NOT claimed as pre-existing: flow-physics-actions (1 red,
the stale-stamp guard) and game-loop-v4 (3 red, scene travel). The A/B that would settle
whether they are mine was killed twice by this machine's OOM killer, so there is no
verdict yet. Both families are documented as timing-sensitive and the box is loaded, but
that is a hypothesis. Owed: re-run the A/B when it is quieter.

A trap that cost five errors and three hunts, worth carrying into the gotchas: anchoring
an insertion on a DECLARATION silently orphans the JSDoc comment above it. It hit
runTick's @PARAM, stopSimulation's options, and mutedFlowObjects' @type - and that last
one surfaced as THREE errors in objectMenu.js, a file this phase never edited, because a
store that loses its annotation infers never[].

Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to base, with
zero new entries by a full list diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
- NEW src/lib/wireValidate.js, a zero-import leaf: isUuid / isFiniteArray / isVec3 /
  isQuatOrEuler, sanitizeTransform, and a VALIDATORS table keyed by message type. THE
  RULE THAT KEEPS IT ADDITIVE: an absent entry ALLOWS, so a peer one release ahead is
  never rejected on shape - only counted as unknown by the dispatcher.
- NEW src/lib/wireErrors.js: per-(peer, type) counters, noteWireError, a rate-limited
  toast after five failures from one pair, and a diagnostics section registered through
  27-B's own seam. A separate leaf because diagnostics.js must stay dependency-free.
- peerHandler: the dispatch chain is named, so a try/catch wraps 440 lines without
  re-indenting any of them. Shape is checked BEFORE anything reads data.type, then the
  type's own shape, then dispatch inside the guard; an unknown type is counted in a real
  else. canApply stays the first POLICY gate.
- THE RAW-STRING BRANCH IS GONE (audit M11). It routed a peer's string into sceneCommand,
  where "/clear all" wipes the scene AND re-broadcasts it - a receiver re-broadcasting,
  which is golden rule 1 inverted. Nothing sends raw strings, so it stood unreachable and
  armed; what is there now is the unknown-type counter that makes version skew visible.
- Every conn reports its own failures: error and iceStateChanged listeners live in
  handleData, the one function all five creation sites already call (four dials plus the
  adopted inbound conn). eventemitter3 swallows an error nobody listens for.
- The appliers that trusted shape are guarded: userData and lockRestore accept only
  arrays, and moveGeometry routes through sanitizeTransform. Counters are dropped on
  disconnect, which is golden rule 3's obligation.

Suite tests/e2e/wire-hardening.test.cjs, 15 checks, ALL PASS. It feeds the REAL dispatcher
through a stubbed conn: null, a raw string, a number, an unknown type, malformed hosts /
userdata / locked, a NaN move, then a VALID move that must still land.

Counterfactuals, each isolating ONE guard (peerHandler restored byte-identical after):
- shape guard removed: a null reaches data.type and the run dies, Cannot read properties
  of null.
- validator removed, try/catch KEPT: the three refused-before-its-applier checks go red
  and NOTHING escapes - the try/catch contains the applier throw.
- validator AND try/catch removed: it escapes, data.hosts.forEach is not a function.
That triple was designed after noticing the obvious version proves nothing: with the
validator in place, no hostile message reaches an applier, so removing the try/catch alone
changes nothing.

A duplicate defence found and documented rather than left ambiguous: the validator refuses
a non-finite transform at the gate, so sanitizeTransform never runs for wire traffic.
Rejection is right for a transform - a partially repaired pose is one nobody sent - so the
sanitiser stays as the backstop for callers that do not pass the dispatcher, and
counterfactual (b) is what proves it fires when the gate is absent.

Gates: npm run build exit 0; svelte-check 359 errors / 47 warnings against a 361/47 base,
with ZERO new entries by a full error-list diff (the two removed come from deleting the
string branch and narrowing the guarded appliers). The baseline could ratchet 361 to 359
at integration; release.yml is not touched by a phase commit.

OWED: the held suites net-handshake, net-locks and object-sync were not run - they need a
dev server up, and this machine has killed five jobs for memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
- .github/workflows/ci.yml: build, check and unit are REQUIRED on every pull request and
  on pushes to main and release/next. Until now the only workflow ran on a v* TAG, so
  CONTRIBUTING's rule (build passes, check adds no new errors) was enforced by whoever
  remembered, and a broken main was found at release time.
- scripts/check-ratchet.cjs + check-baseline.json: the svelte-check floor is DATA read by
  one script, and release.yml now calls that script instead of carrying its own copy. The
  hardcoded number in that shell block had gone stale - it said 362 while the tree
  measures 359 - and a gate whose number is wrong either blocks honest work or waves a
  regression through. Ratcheting down is the project's convention, so --update writes the
  new floor rather than making it an edit in two places.
- vitest + vitest.config.ts + npm run test:unit, with four suites over the leaves that
  import NOTHING: netBackoff (the default schedule as a contract, 27-F's jitter with an
  INJECTED rng, the unbounded retry that must still terminate when asked for a schedule),
  wireValidate (the additive rule - an unknown type is ALLOWED - plus the hostile shapes
  and the nullable sanitiser), hudRichText (a run is text, icon or br; there is no html
  kind, so a hostile string has nothing to become) and meshBudget (the RELATIONSHIPS
  between the ceilings, which is what silently breaks, not the measured numbers).

WHY THE UNIT LAYER IS NARROW: only zero-import modules qualify, so a run needs no browser,
no jsdom, no svelte compiler and no three.js - 41 tests in 153 ms, fast enough to be a
required job. throwVelocity (imports three) and transferLedger (imports svelte/store) are
deliberately NOT in this first cut; padding the list with modules that need a runtime is
how a unit suite becomes a slow second e2e suite.

WHY e2e-smoke REPORTS RATHER THAN GATES: it renders WebGL through SwiftShader on a
GPU-less runner, where this project measures ~4.5 fps, and several suites are documented
as timing-sensitive. Blocking every PR on that before it has been seen green would train
people to ignore a red tick, which is worse than no tick. The comment in the file says
when to flip it.

Verification, since GitHub Actions cannot be exercised without a push: every job's
commands were run locally in order - build exit 0, node scripts/check-ratchet.cjs exit 0
at 359/47 equal to the floor, npm run test:unit 4 files / 41 tests. The ratchet's failure
path was proven separately by tightening the floor (exit 1) and its improvement path by
loosening it.

The gate caught its own author twice: these unit tests first added NINE type errors
(reading .color off a run union whose br variant has no such field, and probing meshBudget
for three functions that do not exist) and then FOUR more (reading through a nullable
return without saying which case the test expected). Both are fixed here; the errors are
the evidence that the check job does something.

OWED, not done here: tests/e2e/net-stress.test.cjs, the small N=4 mesh regression the
stress harness header has promised since B5. It needs a local signalling server and four
browsers, and this machine has killed five jobs for memory today; faking it would be
worse than recording it.

Gates: npm run build exit 0; svelte-check 359 errors / 47 warnings, equal to the committed
baseline, with zero new entries by a full error-list diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
Roadmap 25 A-D, from the hardening audit (H3, H7, M10, L7). An approval could
hang forever on BOTH sides: the joiner sat on Requesting AB12 with no countdown
and no end, the host collected one card per dial with nothing ever dropping
them, and nothing bounded how many peers a full mesh accepts.

- connectionState: APPROVAL_WINDOW_MS (90s), MAX_PENDING_APPROVALS (12),
  SOFT_PEER_CAP_DEFAULT (8) / HARD_PEER_CAP (16), a persisted softPeerCap, and
  approvalStartedAt as the ONE clock the joiner countdown and the host card age
  both read, so the two sides cannot disagree about one request.
- peerApproval: the dial stamps that clock and arms an expiry; expiry cancels
  the request, takes back the optimistic whitelist row and offers Try again.
  peer-unavailable now ENDS the request instead of toasting unreachable while
  the pill still says Requesting.
- peerHandler: the pending queue is bounded, dropping EXPIRED cards before live
  ones; approval REMOVES the waitingForApproval row rather than mutating it
  (audit M10 - the array grew one dead row per join for the tab lifetime); the
  mesh-fill guard refuses past the hard cap.
- Connect: the pill counts down. Toasts: the card shows its age, stays
  approvable past the window, and the approve buttons disable at the cap with
  the reason on the button. Settings: a Session size control.
- Scene: the camera stream is rate-gated to ~20/s (33ms in VR), measured at 6
  sends in 1149ms rather than one per frame (audit H7).

TWO DEFECTS FOUND WHILE COVERING THIS, both fixed here:

- armApprovalTimeout called clearApprovalTimeout defensively to avoid a
  duplicate timer, and that helper ALSO cleared the stamp - so dial wrote the
  clock and the very next line deleted it. Every outbound request lost its
  countdown, the pill fell back to a fabricated 1:30 that never decremented,
  and the host card read asked 1s ago forever. Cancelling a TIMER is not ending
  a REQUEST, so the clock stays there now; the paths that really end one clear
  it.
- The cap was counted off userdata.length in four places. That roster is the
  WHITELIST, written at DIAL time, so a host who dialled sixteen names would
  refuse every approval while sitting alone. sessionSize/roomIsFull count the
  OPEN connections plus you, in one place, unit tested.

The pill no longer fabricates a countdown when there is no stamp: a confident
frozen 1:30 is worse than none, and it is also what made the new countdown
check pass vacuously.

Verification: approval-timeout 18/18 (new suite). Unit 50/50 with a new
connectionState leaf suite. Counterfactuals proven by breaking each guard and
watching it go red - restoring the clock wipe turns the stamp AND countdown
checks red, restoring the whitelist count re-enables the approve button at the
cap - each restored byte-identical. Neighbours green: connect-states 28 (its
exact-match assertion updated for the countdown, still exact on the peer id),
connect-decision 46, hud-content 160, spatial-voice, modal-layering,
vr-peer-approve, roadmap-13-notifications-notes. invite-link-live fails one
check, measured PRE-EXISTING by an A/B against HEAD (21 passes and the same
single failure on both sides, 206s vs 207s); its failing leg dials across the
public PeerJS cloud.

Build green, svelte-check 359/47 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
net-stress.cjs is the MEASUREMENT RIG - a many-minute sweep across mesh sizes
that spawns its own signaling server and refuses any non-localhost APP_URL. Its
header has pointed at net-stress.test.cjs for the quick check since the day it
was written, and that file did not exist. The runner only lists files ending in
.test.cjs, so npm run e2e -- net-stress matched nothing runnable and quietly ran
no checks at all.

The new suite pins, on a THREE-peer mesh, the properties the rig measures that
would be a real regression if they broke:

- the mesh FILLS: a late joiner dials one peer and ends up connected to both
- a broadcast reaches every peer with NO loss, checked by sequence number
- one send's fan-out stays bounded (it is a per-conn loop, never batched)
- under simultaneous load from two senders, a joiner gets both streams whole

The probe rides a REAL move payload with additive fields, which is the rig's own
trick and matters twice over here: it exercises the real applier path, and since
27-A validates every incoming message, a made-up uuid would be rejected by that
very guard - so the probe carries an actual object's uuid, read back off
objectsGroup the way undo.test.cjs does.

ONE CHECK WAS VACUOUS on its first green and is fixed in the same commit: maxSeq
is a running MAXIMUM and the received counts accumulate, so section 2's
60-message blast left maxSeq at 59 and the later two-way check could not fail.
The probe resets its counters between sections and the assertion is exact on
both counts and both sequence numbers.

CHANGELOG: a section under Unreleased for the hardening batch so far - requests
that end, session size, the signaling link that stops giving up, the wire guard,
the runtime surviving a bad frame, and the copyable diagnostics bundle.

Verified: net-stress 10/10 on three peers (233s). svelte-check 359/47 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
Audit finding C1, the only CRITICAL one. A Script node runs on EVERY peer, every
frame, on the main thread inside the shared flow tick - so a while(true) in one
node did not hang its author, it hung the tab of everyone in the session, with no
way out but closing it.

MEASURED, by bypassing the new guard and re-running the suite: one check passes,
then the run dies with Target page, context or browser has been closed, and the
runner axes it at its 480s cap. With the guard in, the same suite is 11/11 in 31s.

- lib/loopGuard.js (NEW leaf, imports nothing): instrument(code) declares a counter
  per run and injects a limit check at the top of every loop BODY. The budget is
  per FRAME because the function is called once a frame - a loop running a thousand
  times a frame is ordinary, one running a million has stopped being a loop. It is
  a SCANNER, not a parser: it knows just enough to tell code from a string, a
  template, a comment and a regex, so a commented-out while is not instrumented and
  a division is not read as the start of one. What it cannot bracket-match it
  REFUSES, and a refusal shows as the node's error badge rather than silently
  running unguarded.
- scriptRuntime: instrument inside compile(), which caches by CODE STRING - so each
  distinct script is transformed exactly once, and an edit re-instruments it and
  clears the old badge for free. Plus a per-node TIME budget, because a merely SLOW
  node returns between frames and no loop counter can ever see it: over 8ms for 30
  consecutive frames pauses the node with a paused: too slow badge, and editing the
  code re-arms it. Binding entry.fn before the call also removed a PRE-EXISTING
  possibly-undefined invocation, which is why the baseline ratchets 359 -> 358.
- Safe mode: opening the app with #safe pauses the flow runtime BEFORE it starts, so
  a scene whose scripts misbehave on load can still be opened and repaired; Resume is
  27-C's existing exit. The hash is the whole mechanism on purpose - a HELD key
  cannot be read at boot (there is no synchronous API for modifier state, only
  events), so a Shift check would look like a second way in while being one the first
  frame could never honour.
- restoreArmed: autosave arms it before applying a snapshot - inside applyRestore, so
  the explicit Restore button is covered too - and flowRuntime clears it on the first
  CLEAN tick. A flag still set at the next boot means that restore never reached a
  working frame, so auto-restore is skipped and the prompt says why, which is what
  stops one bad scene becoming a boot loop nobody can escape. Written straight to
  localStorage in flowRuntime because the import edge runs autosave -> flowRuntime,
  and reversing it would close a cycle into the history family.
- tests: script-guard drives a REAL while(true) node through the runtime (the node
  needs an objectselector and an edge, or nothing resolves a target and the whole
  suite would pass vacuously). helpers.setupPage gains an additive hash option:
  safe mode is read once during onMount, so the page must LOAD with the hash rather
  than have one assigned afterwards. Absent means an unchanged URL.

THREE DEFECTS IN MY OWN SCANNER, each found by RUNNING its output rather than
matching its text, and each now pinned by a unit test: edits were applied in push
order rather than by POSITION, so nested unbraced loops emitted Unexpected token };
a do/while's trailing while was read as a loop header with no body and refused the
whole script; and a slash after return was read as division, because return ends in
an identifier character.

One fixture bug worth recording, because it is a trap the guard itself creates: the
slow-node check first failed because 900k plain additions measure about a
millisecond. The guard caps every script at a million iterations, so a
slow-but-terminating fixture cannot buy time by looping MORE - it has to do more
work per iteration. The suite now measures the fixture in the page first (22.1ms
here) so a fast machine fails the PREMISE rather than the feature.

Verified: script-guard 11/11 (new), 18 new loopGuard unit tests, unit suite 68/68,
build green, svelte-check 358/47 with the baseline ratcheted down to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
Audit H6 and M13.

H6: removing an object dropped the reference and NOTHING else. Its geometry, its
materials and every texture they held stayed resident until the context died, so a
session that imported and deleted the same model ten times paid for ten copies.
deleteObject has been parent.remove(object) and no more since it was written.

M13: nothing anywhere handled webglcontextlost. When the browser takes the context
away the canvas simply stops updating while every other part of the app keeps
answering, so it reads as the whole thing having crashed, with nothing to act on.

- lib/disposeTree.js (NEW leaf, THREE only): keepSet(scene, doomed) works out what
  the REST of the scene still holds in one traversal, and disposeTree(root, {keep})
  frees only what nothing else refers to. THE DIFFICULTY IS SHARING, not freeing:
  clone() shares geometry and material (which is why editOverlays detaches without
  disposing), onionSkin borrows a real mesh's geometry, and a material fanned across
  a selection is one object. Disposing something still drawn does not throw - it
  renders BLACK, later, somewhere else, with nothing to connect it to the delete
  that caused it. Textures are found by scanning a material's OWN properties for
  isTexture rather than a hardcoded map list, because three grows new map slots
  release to release and a list silently stops covering the newest one.
- Call sites: the /delete command, deleteObject, clearSceneLocal (where clear()
  freed nothing at all), both override-replace paths, and autosave's twin swap. The
  keep set spans the SCENE ROOT rather than the replicated group, because
  scene-root helpers share resources with real meshes on purpose; autosave is the
  one exception and says why in place.
- Undo needed nothing, and that was CHECKED rather than assumed: history's
  captureObjectSnapshot calls object.toJSON() and applyPresence restores through an
  ObjectLoader, so no entry holds a live GPU resource. Had it held references,
  disposing on delete would have handed undo objects with freed buffers - the one
  failure mode in this phase that reports nothing at all.
- faceEdit needed nothing either: applyMeshGeo already disposes the previous
  geometry before swapping. Recorded rather than changed.
- Context loss: Scene.svelte listens on the canvas, with teardown beside its
  siblings. preventDefault() is load-bearing rather than a formality - without it
  the browser never fires a restore event AT ALL. Restoring forces a material
  recompile across the scene. ContextLostOverlay says what happened, offers to save
  (.tpscene, which needs no selection where a GLTF export would ask) and to reload,
  and says the scene is intact because it lives in the page, not on the card.

TWO VACUOUS CHECKS OF MY OWN, both caught by the counterfactual rather than by
reading them:
- an orphan invariant comparing renderer.info.memory against a scene walk PASSED
  with disposal entirely bypassed. The two are not a superset relationship - gizmo
  parts and VR helpers are referenced without all being uploaded. Replaced with an
  exact floor-return check, which does fail.
- the floor itself was measured wrong. The gizmo's geometries upload the first time
  they are DRAWN, not when an object is selected, so a warm-up that created and
  deleted inside one evaluate left them for the next section to allocate and read a
  floor of 4 where the truth was 18.

Verified: dispose 14/14 (new), 11 new disposeTree unit tests including the keep-set
counterfactual (a shared texture IS destroyed without it), unit suite 79/79, build
green, svelte-check 358/47 unchanged. Counterfactual with disposal bypassed: three
checks red (floor 19 against 29 held, peak 29 -> 29, clear 37 -> 37), restored
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
…smokes the new suites

The glue commit the execution plan asks for after wave 1, minus one item that turned
out to need no work.

- peerHandler had NO diagnostics logger and 24 console calls. The RECOVERY narrative
  is what somebody needs when reporting a session that fell apart - the id collision
  and rebuild, adopting an inbound conn as the send channel, a signaling link that is
  down, each restore attempt and its give-up, a drop with no goodbye, the reconnect,
  and a failed send - and all of it existed only in a console nobody copies. Those 14
  move to log(), so they ride the copyable bundle 27-B added. The chatty ones (ids,
  hosts, per-send noise) stay on console deliberately.
  diagnostics.js imports only svelte/store and version.js, so this closes no cycle -
  checked by building, since a TDZ cycle in this neighbourhood takes the app down at
  boot and every suite dies in setupPage.
- CI smoke gains the three new SINGLE-PEER suites: script-guard, dispose and
  approval-timeout. net-stress and signaling-reconnect are deliberately NOT added:
  they are multi-peer and meet on the self-hosted signaling box, which a public runner
  cannot reach and should not be pointed at. The workflow header already says two-peer
  suites stay a manual gate; this keeps that promise.
- The plan's third item, registering wire statistics in the bundle, was ALREADY DONE
  by 27-A: wireErrors.js registers a diagnostics section named wire, publishing the
  failure total and the first twenty entries. Nothing called wireStats exists, and
  nothing needs to. Recorded rather than invented to match the wording.

MEASURED, not assumed: signaling-reconnect's visibility check is flaky, and it is not
this change. Three runs of the unmodified branch read 16/17/16 passes, and an A/B
against HEAD's peerHandler failed the same check. The counts disagree in both
directions - 1 -> 1 on base, 1 -> 2 and 1 -> 3 on later runs of identical code -
because the check compares an exact delta against a number read in an earlier await,
so a retry already scheduled by the sections above can land inside its window. Fixed
separately in the suite. An instrumented run capturing the reconnect call stacks is
what settled it: one call, from retryNow, via the online event.

Verified: diagnostics 16/16, net-peer-id 7/7, unit suite 79/79, build green,
svelte-check 358/47 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
NOT flaky - deterministically wrong, which is a more useful thing to know. The glue
commit before this one called it flaky; this corrects the record.

Both checks stage a dead signaling link by shadowing open/disconnected/destroyed on
the peer object and counting reconnect() calls. The app's correct response to a dead
link is to REBUILD the peer, and a rebuild replaces this.peer outright - which
discards the shadows and the counter with it. Every earlier shape of these checks was
measuring an orphan, which is why they read 0, 1, 2 and 3 retries across runs of
identical code, on base as well as on the branch.

An instrumented run named the guard. Before the dispatch the live peer read
open:false, disconnected:false, destroyed:false - a freshly rebuilt peer in the
CONNECTING state, which retryNow deliberately has no branch for - while the event
itself was delivered (a fresh listener counted it) and the object being measured was
not the one that had been stubbed.

dispatchEvent runs its listeners SYNCHRONOUSLY, so stubbing, dispatching and reading
the counter inside ONE page evaluation leaves no window for a rebuild to intervene.
Both checks are rewritten that way, against the peer the app holds at that instant
rather than one captured at setup.

NOT a product defect: retryNow reads this.peer live, so a genuinely disconnected peer
is still retried. Only the fixture was stale.

Verified: three consecutive runs 17/17, where the previous shape gave 16/17/16 and
then 16/15/15. Counterfactual with retryNow neutered: 14 passes and exactly the three
checks red (both retry counts and the schedule reset), restored byte-identical.
svelte-check 358/47 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX
The hardening audit's M3, and the half of it this project had already MEASURED from
the outside: storageUsage.js's `safeGet` exists because "idb.js settles only on the
request's own onsuccess/onerror, so an aborted transaction leaves a promise pending
FOREVER". This is the fix that finding was owed.

- `tx.onabort` rejects, in all four wrappers. THE TIMING IS THE WHOLE POINT and is
  why the first version of the test passed for the wrong reason: abort a transaction
  with a request still in flight and that request errors FIRST, which bubbles to
  `tx.onerror`, so the old code happened to settle. Abort once every request has
  succeeded and `onabort` is the only event that fires - that is the case that hung,
  and it is what the seam now reproduces.
- `withTimeout` bounds every operation at 10s. Rule 1 covers the aborts the browser
  reports; the bound covers the class it does not, where the request object simply
  never fires again. The error carries `timedOut` so a caller can branch without
  matching a string, and it logs through 27-B's diagnostics ring.
- `open()` is cached, with the cache dropped on `onclose`, on `onversionchange`, on a
  failed open, and on the `InvalidStateError` a stale handle throws (which `withDb`
  retries once - that retry is what pays for the cache). Every op used to open its
  own connection and a storage scan makes a few hundred in a burst.
- 10s IS MEASURED, not assumed: a 25MB put - larger than the Explorer's own import
  cap - takes ~480ms here, so the bound has ~20x headroom over the largest write the
  app can make. The suite asserts a 5x margin, so a change that makes writes genuinely
  slow turns red instead of silently failing a user's import.
- storageUsage.js's comment said the fix was "still owed"; it now says it landed and
  why the 5s bounded read stays anyway (a panel must not wait 10s per key).

Counterfactuals, each proven by breaking the code and watching the suite:
- `tx.onabort` removed -> "an aborted transaction REJECTS rather than hanging" reads
  `still waiting in 5369ms`, which is the bug verbatim (3 checks red).
- the `Promise.race` bound removed -> the stalled transaction reads
  `still waiting, timedOut=false` after 5263ms (2 checks red).
- the `open()` cache removed -> "20 reads reuse one connection" reads `20 new opens`.
- unit: the same three properties with no browser, including a `still waiting` race
  that says what an unbounded await does.

Suites: storage-hardening NEW 10/10. Held green: autosave-object-flows, explorer-storage
(239s for the pair). Unit 86 tests / 8 files (base 79 / 7). svelte-check 358/47,
exactly the committed baseline. Build green with the dev server stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um
…says when it fails

The audit's M3 (re-entrancy, no quota feedback) and M5 (a full GLTF export of the whole
scene on the main thread every 30s, worst exactly when the scene is biggest).

- ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot` unconditionally and a
  snapshot is several awaits long, so on a scene whose export outlasts the debounce
  every tick started a FRESH full export while the previous one ran, each parking and
  unparking the same objects. A save asked for mid-write is now folded into the one in
  flight and scheduled once when it finishes. `saveNow` deliberately does NOT fold - it
  is the path whose promise is "it is on disk when I resolve", so it waits its turn.
- THE CADENCE ADAPTS. `exportScene` measures itself and `cadenceFor(ms)` - pure, and
  exported so it can be asserted directly - turns that into the wait: 150ms or less
  keeps 30s, then it doubles per doubling of the cost to a 5min cap. Derived from ONE
  measurement rather than a stateful "double it, halve it", which oscillates. The
  3-minute safety-net interval respects it too, or the backoff buys nothing.
- THE PROBE STRINGIFY IS GONE. `JSON.stringify(snapshot).length` serialised everything
  and threw it away to learn a number, and then `idbPut` walked the same graph again.
  `estimateSnapshotBytes` reads the `.length` of the handful of base64 strings that ARE
  the bytes (GLTF buffers/images, animated-import file bytes) and estimates the rest
  from counts. MEASURED at 0.010ms against the stringify's 9.0ms on an 8MB snapshot.
- A FAILED AUTOSAVE IS SAID OUT LOUD. A full disk reached `console.log` and stopped
  there, so crash recovery had silently switched itself off with nothing to tell the
  user - the worst shape a safety feature can fail in. Now a STICKY toast naming what it
  means for recovery, carrying "Manage storage", cleared by the next successful save;
  the reason also lands in 27-B's diagnostics bundle through a new `autosave` section
  (cadence, last cost, last error - the single most useful line in a lost-work report).
  `isQuotaError` tests all three spellings; Firefox's is a legacy numeric code.
- Clearing `dirty` is now conditional on `dirtyPulse` not having moved during the
  export, the held-body `lastWritten` rule: a change made DURING a save is not in the
  bytes that save wrote.
- The Storage panel renders the cadence in words, the last export's cost, and - only
  when it has backed off - why. An adaptive interval nobody can see is indistinguishable
  from autosave being broken.

Counterfactuals, each proven by breaking the code:
- re-entrancy guard removed -> three ticks during one save write 3 snapshots, 0 coalesced.
- the failure report removed -> all five quota checks red, `lastError` null.
- the cadence frozen at 30s -> "the live cadence is the one that measurement implies"
  reads `509ms -> 30000ms`.
- the probe stringify restored -> "at least 20x cheaper" reads 2.270ms vs 2.1ms.

One suite trap worth the line: the panel was opened with a page-side
`import('/src/lib/storageUsage.js')`, which binds a SECOND module instance once vite has
timestamped the app's copy - it passed once and then failed in two counterfactual runs
for a reason that had nothing to do with the counterfactual. It goes through
`window.__stores` now.

Suites: storage-hardening 28/28 (10 -> 28). Held green: autosave-object-flows,
explorer-storage, diagnostics (4 suites, 296s). svelte-check 358/47. Unit 86/86.
Build green with the dev server stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um
…s it that way

The audit's M4. It counted 136 bare `localStorage.setItem` calls in 25 files; the tree
has grown since, and the real number measured here is 507 call sites across 94 files.

WHY IT MATTERS, in one sentence: `setItem` throws synchronously in Safari private mode
and on a full quota, and most of these sit inside `$effect`s and store subscribers - so
the throw does not merely fail to persist a setting, it KILLS THAT SUBSCRIBER for the
session, and the UI it drives stops updating. The suite reproduces exactly that with the
wrapper removed: toggling a setting in a broken world leaves it stuck at its old value
and raises QuotaExceededError out of the subscriber. Reading is not safe either, which
is less well known - in a sandboxed iframe merely TOUCHING `window.localStorage` throws
SecurityError, which every `typeof localStorage === 'undefined'` guard in this codebase
misses, and there are about a hundred of them.

- `src/lib/safeStorage.js`, a leaf that imports NOTHING (it is reached from stores, from
  components and from both sides of the history-cycle family, so any import here is a
  future cycle - and it is what lets the unit layer test it with no browser).
  get/set/remove per the spec, plus getItem/setItem/removeItem/clear/keys so the codemod
  is ONE IDENTIFIER per line - a rename a reviewer can check by eye rather than 507
  chances to move a semicolon.
- THE FALLBACK IS PER-KEY, which is what makes the promise honest: a setting whose write
  failed is kept in memory, so it still APPLIES this session and reads back as what you
  set; it just does not survive a reload. A SUCCESSFUL write drops the shadow again, or
  a stale one outvotes the real value forever.
- `keys()` enumerates through `length`/`key(i)` rather than `Object.keys`, the form
  dragWindow used: that happens to work on the real Storage exotic object and returns
  METHOD NAMES on anything else implementing the interface.
- The codemod, plus two hand cases the regex could not see: units.js's
  `const ls = typeof localStorage !== 'undefined' ? localStorage : null` alias, and
  dragWindow's `Object.keys(localStorage)` sweep.
- `scripts/check-storage.cjs` + `npm run check:storage`, wired into ci.yml's `check` job.
  Without it the codemod decays on the next feature, because the file you are editing
  still shows you ninety-three examples of the old way. `src/app.html` is ALLOWED with
  its reason spelled out: an inline <script> applying the saved theme before first
  paint, which runs before any module exists to import.
- A `storage` diagnostics section, so a bundle says whether persistence is working.
  `degraded` is the line worth having - settings applying but not surviving a reload is
  otherwise completely invisible, and it is what "my preferences keep resetting" is.

BASELINE RATCHETED 358 -> 357. The codemod removed one pre-existing error for free:
commandsHandler called `localStorage.setItem('showGrid', false)` with a boolean, and
safeStorage takes `any` and coerces the way Storage does. Identified by diffing the full
error sets against a clean checkout, not guessed.

Counterfactuals, each proven by breaking the code:
- the try/catch removed -> 5 checks red, incl. the real bug: a setting toggled while
  storage is broken reads `false, raised QuotaExceededError`.
- the memory fallback removed -> "the setting still APPLIES" reads back null.
- a bare `localStorage.setItem` added to viewPrefs.js -> check-storage exits 1 naming
  the file and line; removed -> exits 0.
- unit: the no-storage, throwing-setItem and throwing-ACCESS worlds, plus a
  side-by-side bare call that does throw in the same world.

Suites: storage-hardening 37/37 (28 -> 37). Held green: autosave-object-flows,
explorer-storage, settings-autorestore-colors, explorer-views, docking, units, packs,
packs-explorer, panel-shortcuts, sessions-packs, workspace-restore (13 suites, 954s).
PRE-EXISTING RED, A/B'd against this branch's own previous commit and failing
identically there: packs-drop (2 checks; the documented drag-drop-simulation cluster).
Unit 98/98 (86 -> 98, 10 files). svelte-check 357/47 against the new floor. Build green
with the dev server stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um
The audit's M9. Mute only ever set `track.enabled = false`, and nothing in this module
has ever called `stop()`. A disabled track is still a LIVE track: the tab keeps its
recording indicator, the OS keeps the device claimed so nothing else can open it, and
both stay that way for the life of the page after one press. That is a trust problem
before it is a resource one - the indicator says "this page is listening" and it is not
true. `leaveSession` never touched voice at all, so it survived leaving the session too.

- `releaseMic()` stops every track, drops the `self` analyser, and CLOSES THE OUTGOING
  CALLS. The last part is not tidiness: a MediaConnection carries this stream, and
  `callPeer` skips a peer that already has one - so leaving a dead channel up would make
  the next re-acquire reach nobody. Closing means `ensureStream` re-calls everybody,
  which costs a renegotiation and is the only version that works. INCOMING calls are
  deliberately left alone: listening never needed a microphone, and turning your own mic
  off is not a request to stop hearing other people.
- Called from: the mic toggle going OFF (immediately - you said so, and the indicator is
  what you are watching), the VR mic mode reaching 'off', and `leaveSession`.
- PUSH-TO-TALK releases after a 3s IDLE GRACE rather than on the keyup. That is the one
  piece of policy here, and it is there because re-acquiring costs a `getUserMedia` AND a
  renegotiation with every peer: releasing instantly would make the second sentence of a
  conversation arrive late. A few seconds of indicator after you stop talking is active
  use; forever is the bug.
- `releaseMic` also clears `micActive`, and THE TWO-PEER SECTION IS WHAT FOUND THAT: with
  the flag left true and no stream behind it, the toolbar claimed an open mic and the
  next press was read as "off", so the peer was never called at all. Measured as B seeing
  `incoming: 0` through a 20s wait. The state has to agree with the device.
- THE SPEAKING POLL used to be armed once at init and run at ~7Hz for the life of the
  tab, with no microphone, no peers and nothing to measure. `syncPoll` arms it only while
  something is measurable (our stream, or any call) and stands it down otherwise,
  clearing `speakingPeers` when it does - nobody can be speaking when nothing is
  measured.
- The AudioContext is deliberately NOT closed: `audioEngine` owns it for the whole app
  since #22 A1, so closing it here would silence music, sounds and pings.

Counterfactuals, each proven by breaking the code:
- `stop()` swapped back for `enabled = false` -> 5 checks red, reading
  `{"stream":true,"live":1,"enabled":0}` - a live-but-disabled track, which IS the bug.
- the unconditional `setInterval` restored -> "nothing is claimed and nothing is polling"
  reads `polling:true` with no mic and no peers.
- `releaseMic()` removed from `leaveSession` -> the mic survives leaving the session
  (`live:1, enabled:1`).

Suites: storage-hardening 51/51 (37 -> 51, now two peers for section 5). Held green:
voice-ptt, spatial-voice, autosave-object-flows, explorer-storage (5 suites, 495s on a
freshly restarted server). PRE-EXISTING RED, A/B'd against BOTH this branch's previous
commit and the lane base 87c9d72, failing identically on all three: net-reconnect
("B's new object reaches A after the heal"). svelte-check 357/47. Unit 98/98. Build
green with the dev server stopped.

One method note: after the A/B checkouts above, every suite died in setupPage's
`waitForFunction` with `$peers` null inside Scene - the documented mid-session HMR churn,
not a regression. A dev-server restart and a curl-grep for a new symbol cleared it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um
…write

Found reviewing my own phase 2 diff (b5898ec). That commit added the right guard -
"a change made DURING the export is not in the bytes just written" - and then read
`markAtStart = get(dirtyPulse)` immediately before `idbPut`, which is AFTER the GLTF
export has already finished. The export is the slow part and therefore the entire
window the guard exists for, so as written it compared a stamp taken after the risky
period against itself and cleared `dirty` unconditionally in every real case.

It is read on the first line of `writeSnapshot` now.

Not a lost-work bug in practice - the `markDirty` that raced the save also armed a
fresh debounce, so the edit still reached disk 30s later - but `isDirty()` read false
in between, and that store is what Settings and the window title's dirty asterisk
consult. The honest version of the guard is the one that measures the right window.

Suite: two checks in storage-hardening - an edit made while a snapshot is being
written stays unsaved, and a quiet save still clears the flag (a guard that only
asserted the first half would pass with `dirty` never cleared at all).

Counterfactual: the unconditional `dirty = false` restored -> "an edit made while a
snapshot is being written stays unsaved" reads `(false)`, 56 of 57.

Suites: storage-hardening 57/57 (51 -> 57 checks; the 51 in f731555's body was a
miscount - 57 is the measured number). svelte-check 357/47. Unit 98/98.
check:storage clean. Build green with the dev server stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um
…indowed object list

Roadmap 26 Stage 0 (hardening audit M6, M1, M2). The receive path ran an unbounded
amount of work per incoming object; this bounds all three places it did so.

WHAT

- `pokeScene()` in sceneStore replaces all 117 `objectsGroup.update((v) => v)` call
  sites across 37 files. Eighteen subscribers hang off that store and several traverse
  the whole tree, so a 1,000-object handshake was ~8M node visits, synchronously, on
  the receive path. One flush per microtask normally; one per ~16ms while an ingest
  batch is open (`beginSceneBatch`/`endSceneBatch`, refcounted). The seam lives IN the
  store because all 37 files already import from it, so it costs no import edge — which
  matters when the pokers include peerHandler, flowRuntime, autosave and history.
  A microtask, not rAF: it lands before paint AND still runs in a hidden tab. Batch
  mode uses a timer for the same reason. `sceneRevision` is bumped on every flush, for
  a subscriber that wants to cache a traversal (and for 26-A's meter).
- `createObject` is a QUEUE with an 8ms slice (`INGEST_SLICE_MS`). The dispatcher never
  awaited it, so N object messages started N overlapping main-thread GLTF parses in one
  task and their completion order was accidental. Objects now apply strictly in arrival
  order, the drainer yields a MACROtask every 8ms (a microtask chain never returns to
  the browser), and the whole drain holds a scene batch. `clearSceneLocal` drops the
  queue — roadmap section 5's "the ingest queue drops on clear".
- Toasts' "Receiving objects" reconciliation was `getObjectByProperty` — a full tree
  walk — TWICE per outstanding uuid on EVERY poke. One traversal into a Set, then
  lookups: O(objects + outstanding) instead of O(both).
- The object list VIRTUALISES above 500 visible rows: the same `Objects.svelte` row
  component in a new `flat` mode over the flattened `visibleObjectRows` — the one array
  the keyboard walker, Ctrl+A and the type-ahead already read their order from — with
  two spacer divs for what is off screen and a keyboard follow that moves the window.
  Below the threshold the recursive tree is unchanged. The scroller is found by real
  SCROLLABILITY (it is flowbite's Listgroup, whose element we do not own) and the row
  height is MEASURED, never assumed, because the spacers are in pixels.
- audit M1: `sendObjects` builds its uuid list PER CALL (it was a module-level array
  that `countObjects` pushed onto and only the timer emptied, so two approvals 400ms
  apart cross-contaminated both joiners' `loading` lists and `count` was a running
  total), and resolves its connection INSIDE the 500ms timer, bailing when it is gone —
  `peer.connections[peerId]` is undefined mid-dial and closed when the joiner gave up,
  and both used to throw inside a timer where nothing catches it.
- audit M2: a `loading` batch records its SENDER (local only — the message is
  unchanged), and is cleared by that peer's teardown, by a parse that rejects
  (`noteLoadFailed`), by a scene clear, and by a 60s stall. Nothing could clear it
  before: the only writer removed a uuid when its object APPEARED, and an object that
  never arrives never appears.

COUNTERFACTUALS (suite `scene-poke`, 32 checks, one page)

- Measured IN THE SAME RUN: the bare `objectsGroup.update((v) => v)` this replaced
  notifies 500 times for 500 calls where `pokeScene` notifies once.
- Measured IN THE SAME RUN on the same payloads: the old ingest shape (parse + add +
  bare poke, 400 objects, one task) holds the main thread for 215ms and renders 2
  frames; through the queue the worst hitch is 86ms and 7 frames render.
- Broken then restored: `handleDisconnected`'s batch clear removed -> "the sender
  disconnecting clears the batch" red. `VIRTUAL_MIN` raised to 5000 -> "the list draws
  a WINDOW" reads 720 rows in the DOM, the spacers and the mode attribute go red too
  (4 checks). Both restored, suite green again.

GATES

- svelte-check 352 errors / 47 warnings, DOWN from the 357/47 floor — typing `loading`
  and `loadingcount` in appStore (they were written as arrays through a splice-in-place
  and inferred `never[]`) removed 5 pre-existing errors. `check-baseline.json` ratcheted
  with `--update`.
- `npm run build` green with the dev server stopped.
- Held suites at or above base: object-list-keys, objectlist-search, object-search,
  object-delete, selection-extras, clear-scene, inspector, dispose all green.
  `multi-select` ("member transforms replicated to B") and `undo` ("the placed position
  replicates") are red — and reproduce IDENTICALLY at base 7646fc2 with src reverted,
  so they are pre-existing and not this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a desktop Statistics panel

Roadmap 26 sections 2 and 3. There was no scene-level budget anywhere — no object,
triangle, draw-call or texture ceiling — and `renderer.info` had exactly ONE reader in
the whole app: the VR stats plate. So on a desktop, where every heavy scene is built,
there was no way to see draw calls, triangles, GPU object counts or a single frame-time
number, and a diagnostics bundle carried none of them.

WHAT

- `src/lib/sceneBudget.js`, a LEAF (svelte/store, the scene store, and `inputDevice`
  which is itself import-free). That matters: peerHandler counts wire traffic through it
  and commandsHandler publishes its ingest backlog, and both sit inside the documented
  import cycles. Anything it cannot reach REGISTERS instead (`registerMetricSource`, the
  `registerDiagnosticsSection` shape).
- `BUDGETS` is the roadmap's section-2 table as DATA, so the meter, the panel and (next)
  the ingest fork and the auto-stops read ONE source. `tierOf` / `worstTier` /
  `budgetRows` are PURE, which is the part that has to be right and the part that needs
  no browser and no GPU to test.
- TIERS WITH ACTIONS, NOT WALLS: green does nothing, amber shows, red is what 26-C and
  26-G will read. Nothing here refuses anything — a budget that stops you working is a
  budget people switch off. TWO PROFILES (desktop vs VR/mobile), because the same scene
  is fine on one and fatal on the other, and on a headset the tab is KILLED rather than
  slowed.
- `unknown` is its own answer and never darkens the meter. "Not measured" is not "bad",
  and a meter that cries wolf before the first sample is one nobody reads.
- The sampler is our OWN rAF loop, ~2 samples a second: frame time measured from the
  browser's own callback cadence is exactly the quantity "did the window freeze" asks
  about, and it keeps this a leaf that Scene.svelte does not have to know exists. It
  carries p50/p95/p99 over a 240-frame ring (FPS averages the stutter away; p95 is the
  frame you feel), `PerformanceObserver('longtask')` where it exists and a row that says
  "not available" where it does not, `performance.memory`, the renderer's render and
  memory counters, the scene walk, and every registered source.
- WIRE TRAFFIC PER TYPE (audit H7's measurement): counted in `broadcast` and at the
  dispatcher's entry. Message counts are EXACT and free; bytes are a 1-in-16
  `JSON.stringify` sample scaled up and labelled "≈", so the measurement cannot become
  the cost being measured.
- UI: a `Statistics` row in the burger menu (`#open-stats`) and a coloured dot in the
  object-list status line, whose tooltip names what is over budget and which opens the
  panel — a warning you cannot act on is a decoration. The panel is a floating window,
  not a modal, because the point is to watch the numbers move while you work.
- `budgetSummary()` is registered as a diagnostics section, so a report carries the
  numbers (audit H4 + section 3's last row).

COUNTERFACTUALS (suite `scene-budget`, 34 checks, one page)

- `tierOf` forced to always answer green -> 6 checks red, including the status-line dot
  and its tooltip, which is the whole user-facing half.
- `registerMetricSource('ingestBacklog', …)` removed -> "a registered source reaches the
  sample" red, so the seam is proven rather than assumed.
- The diagnostics section registration removed -> "the bundle carries a scene-budget
  section" red.
All restored; suite green again (34/34).

NOTES

- The status-line meter's click is a DIRECT listener via an action, not `on:click`:
  Controls.svelte is written in the `on:` style throughout, so an attribute handler is a
  hard "mixing syntaxes" error there and the `on:` form costs a deprecation warning —
  and a delegated handler inside a panel can be swallowed on its way up anyway, which is
  the rule this codebase already keeps for panel chrome.
- Physics body count is in the budget table's spirit but NOT yet wired: `physics.js` can
  publish it through `registerMetricSource` with no import edge, and that is left to
  whoever touches that file next.

GATES

- svelte-check 352 errors / 47 warnings — exactly the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held: scene-poke (32/32), diagnostics, wire-hardening, object-list-keys,
  controls-state, vr-stats, sidebar-toggle all green. `panels` is red on
  "save format dropdown opens" — PRE-EXISTING and visible in the source: the JSON format
  button renders behind `{#if showGltf || showJson}` and both default to false since B3
  demoted JSON behind the export cog, so `getByRole('button', {name: /json/i})` has
  matched nothing since that change. Not this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e says so before it opens

Roadmap 26 section 4, Stage 2 (the ingest gate). A scene arriving over the wire
announces itself FIRST — `{type:'loading', count, uuids}` — and only then sends the
objects, so there is exactly one moment where its size is known and nothing has been
applied. Past that moment a 4,000-object scene is simply happening to you.

WHAT

- `ingestVerdict(current, incoming, profile)` in sceneBudget, PURE: the total, the
  tier, the object-budget limit, and `allowed` — how many fit before the scene crosses
  into red. What is ALREADY in the scene counts (2,900 here plus 500 asks). Amber warns
  and does not ask; red asks — the tiers-with-actions rule 26-A set up.
- THE GATE lives in `createLoader`, the one place a `loading` announcement lands. On a
  red verdict it HOLDS the ingest queue that 26-B built, so every object that arrives
  after it is parked — parsed or not, with no second code path and nothing to unwind.
  The 60s stall timer is disarmed while the question is open: the objects are parked,
  not missing, and clearing the progress bar under an open fork would be a lie.
- THE FORK, three ways, as a sticky card mirrored from an `ingestGate` store (the
  `restoreAvailable` idiom, so commandsHandler never imports the UI), with `noClose` —
  an X would leave the transfer stalled with nothing left to resume it:
  Load all · Load the first N · Cancel. "The first N" caps the drainer; everything past
  the cap is DROPPED and counted as arrived, so the bar does not wait out the stall for
  something that is never coming. Cancel drops the queue and clears the bar.
- LOCAL ONLY. Nothing is sent. The peer is not told we declined — that is a fact about
  THIS device's budget and there is nothing for them to do about it. They see us with
  fewer objects, which is what happened.
- THE FILE HALF: `requestLoadPayload` — the one entry point a PERSON reaches by opening
  a .tpscene or pressing Load in Sessions — counts the payload (`countPayloadObjects`,
  nested children included, the unit the budget is stated in) and asks before
  replacing the scene. Deliberately NOT in `applySession`: travel, a peer's proposal,
  an autosave restore and rejoin all go through that, and a replicated hop must never
  stop at a dialog nobody is standing at (the travel-node rule). The file gets TWO ways
  out, not three: "load the first N objects of this file" makes a scene nobody saved,
  which the user would then re-save over their own file silently truncated. A stream is
  divisible; a document is not. The file is compared against the budget ALONE, because
  it replaces the scene rather than adding to it.

COUNTERFACTUALS (suite `ingest-gate`, 29 checks, one page)

- `createLoader`'s gate disabled -> 6 red: nothing parks, the scene is touched while
  the question should be open, the card never appears, and the rest of the run cannot
  find the fork's buttons.
- The file-open ask removed from `requestLoadPayload` -> the dialog never appears and
  the run cannot answer it.
Both restored; suite green again (29/29).

GATES

- svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held green: scene-poke (32/32), scene-budget (34/34), sessions, tpscene, clear-scene,
  and the two dedicated handshake suites object-sync and net-handshake — which cover
  exactly the late-joiner receive path this gate sits on ("a late joiner receives EVERY
  object", "every message left over an OPEN connection").
- `scene-levels` is red on nine two-peer checks and reproduces IDENTICALLY at base
  7646fc2 with src reverted (same nine names, 206s vs 205s). Pre-existing, not this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n window pauses drawing

Roadmap 26 section 4, Stages 3 and 4. Stages 0-2 stop the app freezing on the way IN;
this is what happens once a heavy scene is already here and the device cannot keep up.
The roadmap's principle holds throughout: every stop is ONCE per streak, REVERSIBLE,
and SAYS SO — an automatic action the user cannot see and cannot undo is just a
different kind of broken.

WHAT ALREADY EXISTED, AND WAS WIRED TO RATHER THAN REBUILT

- 27-C put a try/catch around the physics step (audit M7). A step that THROWS already
  stops the run once. Nothing caught a step that is merely too SLOW.
- 27-D shipped the per-node script budget (audit C1) — Stage 3's second bullet. Done.
- 27-G shipped the context-loss half of Stage 4 (`ContextLostOverlay`, the canvas
  listeners, the recompile sweep). The new overlay stands DOWN whenever that one is up;
  two cards describing two different failures at once would be two cards arguing.

WHAT

- `src/lib/overloadGuard.js`, a LEAF (stores + sceneBudget). `createStreakWatch` is PURE:
  N bad samples IN A ROW, firing exactly once per streak. Consecutive, never cumulative
  — one 300ms hitch while a texture uploads is not a scene too heavy to run, and a
  trigger that fired on it would stop somebody's simulation because they imported a
  picture.
- STAGE 3, PHYSICS: `step()` times `stepInner`; 30 consecutive steps over 24ms (a 24ms
  step on a 16.7ms frame makes every frame late before rendering starts) stop the run
  ONCE with a toast that names the reason and carries Resume, so the stop is never a
  dead end. The real step and the test hook share ONE stop path so they cannot drift.
- STAGE 4, THE FREEZE: sceneBudget's frame loop (26-A) feeds a streak of 10 frames over
  250ms — 2.5 seconds of a window that has stopped answering — through a new
  `registerFrameObserver` seam, so the budget module keeps knowing nothing about pausing.
  Three things are NOT a frozen scene and never trip it: a backgrounded tab (the browser
  throttles rAF to ~1Hz on purpose), the first frame after the tab returns (its delta
  spans the whole absence), and a 3s grace after Resume (the first composer frame
  recompiles).
- THE PAUSE IS REAL: `Outline.svelte`'s render task — the one place a frame is drawn —
  returns early while `renderPaused` holds, so a device that cannot keep up does no GPU
  work at all. NEVER in a headset: the XR compositor needs frames, and a paused session
  is a frozen world strapped to the user's face with no overlay (DOM is invisible in VR).
- THE OVERLAY (`RenderPausedOverlay.svelte`): Save now · Reduce · Resume, saying that
  nothing is lost and that autosave keeps running.
- REDUCE sets the NEWEST top-level objects aside until what is still drawn fits the
  object budget — and does it with a render LAYER, NOT `visible = false`. That is the
  design's load-bearing decision: autosave exports through GLTFExporter with no options,
  and `onlyVisible` DEFAULTS TO TRUE, so a hidden object is silently DROPPED from the
  recovery snapshot. Reducing a scene would quietly delete its newest objects from the
  one copy meant to survive a crash, while the overlay promised autosave carries on. A
  layer is invisible to every serializer, never replicates, and is honoured by the
  camera cull and the raycaster alike: a reduced object is still in the scene, the save,
  the wire and the undo stack — only not drawn or picked HERE. Original masks live in a
  WeakMap, never on userData, so they cannot leak into a file. "Show them again" undoes.
- THE RESTORE PROMPT names the snapshot's object count against this device's budget
  before restoring (Stage 4's last bullet) — a phone that died restoring a big scene
  comes back to exactly that prompt, and the count is the reason. It reads the same
  `ingestVerdict` the 26-C gate does.

COUNTERFACTUALS (suite `overload-guard`, 34 checks, one page)

- Measured IN THE SAME EXPORT: a layer-reduced object is in a default GLTFExporter
  output while a `visible = false` twin is dropped — the hazard, proven rather than
  asserted.
- The Outline render gate disabled -> "no frame is drawn while paused" red (684 frames
  in 600ms instead of 0).
- The hidden-tab guard disabled -> "thirty 1-second frames in a HIDDEN tab never pause"
  red: every tab switch would have paused the scene.
Both restored; suite green again.

GATES

- svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held green: scene-poke, scene-budget, ingest-gate, ai-flow-physics,
  flow-physics-collider, physics-ground-bounds.
- The slow-step stop NEVER fired in any physics suite across two full runs (no "too
  slow" toast anywhere in the logs), so it cannot be behind their reds. Those are
  `physics-discoverability`, `flow-physics-nodes` and `physics-kinematic` — the standing
  pre-existing reds CLAUDE.md's 21-B entry already names as A/B'd against base — and
  suites that died at `h.connect` ("could not press Connect" / "could not approve"),
  i.e. signaling on a saturated box before any physics code ran.
- The first battery run died once inside `overload-guard` with Playwright's "Resulting
  promise was garbage collected" after 3,200 real meshes on a swap-full box. The Reduce
  fixture now uses empty Groups (the budget counts tree NODES, so a Group counts exactly
  like a mesh) and the suite is green standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

The freeze trigger in d322e7a paused rendering on ANY ten consecutive frames over
250ms. A software-rendered page lives at ~2.5fps — 400ms frames, permanently — so the
"Rendering paused" overlay appeared during ORDINARY non-GPU e2e suites and covered
their clicks: `#render-paused intercepts pointer events` 23 times in one battery,
turning physics-colliders red on a click timeout and adding one to
physics-discoverability. The new suite could not see it, because it ran on a GPU page
and drove the trigger directly — found by reading the held-suite logs.

The same fault reaches a real user: a weak GPU drawing a scene of twelve boxes. Pausing
that helps nothing. There is nothing heavy to set aside, Reduce would reduce nothing,
and the person loses a window that was slow but still ANSWERING.

WHAT

- `sceneIsHeavy()`: objects, triangles or draw calls at amber or worse for this profile,
  read from sceneBudget's `sceneMetrics`. The streak only counts while it is true, so a
  light scene cannot build one at all. The scene-size axes only, never frame time — that
  would make the rule circular. An `unknown` reading (nothing sampled yet) is not heavy,
  so a freshly booted page can never be paused before the first sample.
- This is also simply what the overlay already SAYS: "the scene is too heavy for this
  device". The trigger now agrees with its own copy.

SUITE `overload-guard` 34 -> 36 checks

- New guard: "forty 400ms frames on a LIGHT scene never pause" (+ its premise).
- The freeze and hidden-tab checks now set a HEAVY reading first. The hidden-tab one
  would otherwise pass VACUOUSLY, since a light scene never pauses whatever the tab does.
- The suite leaves a light scene behind after Reduce: 3,200 objects is heavy by
  definition, and on a saturated box the real frame loop is entitled to pause over it.

COUNTERFACTUAL

- `sceneIsHeavy` forced to always answer true -> "forty 400ms frames on a LIGHT scene
  never pause" and its premise go red. Restored; 36/36 green.

REGRESSION CHECK (same battery that found it)

- `render-paused intercepts pointer events`: 23 -> 0.
- physics-colliders: red -> ALL PASS.
- physics-discoverability: now runs to completion with no click timeout; its remaining
  FAILs are the standing pre-existing reds CLAUDE.md's 21-B entry names as A/B'd against
  base. Neither 26-G trigger fired anywhere in that run (0 "too slow" toasts, 0 pauses),
  so they cannot come from this lane.

GATES: svelte-check 352/47 (the floor); `npm run build` green with the server stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hardening wave 1 — the audit's Critical finding, five Highs, and the two phases nobody started
Hardening wave 2 — 27-H storage: IndexedDB that always settles, autosave that measures itself, and a mic that is given back
… frame

Roadmap 26 section 6. The section-2 budget numbers were estimates; this measures them.

- tests/e2e/scene-stress.cjs: the manual rig (like net-stress.cjs). Per scene size:
  seed/import cost + long tasks, frame p50/p95/p99 idle AND orbiting, draw calls and
  triangles per display frame, geometries/textures, heap, object-list render ms, one
  autosave export (ms, bytes), optionally physics over the scene (bodies, step p50/p95,
  whether 26-G's stop fired) and a second peer joining (time-to-synced). Names the GPU and
  refuses to treat a software rasteriser as data.
- tests/e2e/sceneStressProbe.cjs: the in-page probe both the rig and the suite drive, so
  the regression covers the real measurement code. Everything timed is timed in the page.
- FOUND AND FIXED: the meter's triangles/calls read ONE fullscreen pass. renderer.info
  auto-resets per render() and a desktop frame is 13 render() calls, so 1,000 boxes read
  "1 call, 1 triangle": those budgets could never leave green and 26-G's sceneIsHeavy was
  asking about objects alone. sceneBudget now wraps the renderer instance's render and
  divides the sum by display frames. autoReset is untouched (VRStats, diagnostics and a
  reset-then-render test read what they always did) and it works in XR. Stopping the
  sampler hands back the original function.
- FOUND AND FIXED: the loading stall timer (26-B M2) measured duration, not silence. A
  joiner receiving 3,000 boxes was still landing ~10/s when the bar cleared at 63s and a
  toast said 1,085 objects "never arrived"; all arrived by 180s. Every arrival re-arms it.
- Metric sources the rig needed, registered from their own modules: bodies and
  physicsStepMs (physics.js), autosaveExportMs/autosaveBytes (autosave.js), syncMs and
  syncObjects (commandsHandler: announcement -> last object on the receiver's own clock;
  null for a batch closed unfinished).
- BUDGETS retuned from the measurement (Radeon 890M, 1280x720): desktop calls
  [1000,2000] -> [2000,4500] (1,943 calls = 60fps; 4,446 = steady 30fps; 5,323 = p95 50ms)
  and triangles [1M,3M] -> [4M,8M] (6M/frame held 60fps). Required, not optional: the
  corrected counter reads ~2x objects (the shadow pass), so the old tiers would have made
  a 520-box scene "heavy" and armed 26-G's freeze streak in non-GPU suites. VR columns
  unchanged (owed on a headset). Full tables are in the lane handover.
- Measured, not fixed here (handed to 26-D): ingest is FRAME-BOUND. 3,000 objects take
  ~180s to land while the joiner draws, 5.6s with drawing paused.

Counterfactuals (each broken, suite red, restored):
- countRenderCalls a no-op: 7 red (calls/triangles 0, not wrapped, doubling, stop/start)
- 'bodies' source renamed: 4 red (no-sim reading, sim count, stopped run, rig row)
- the sync `complete` flag forced true: 1 red (closed batch reports a fast sync)
- 'autosaveExportMs' source renamed: 1 red
- the stall re-arm removed: 1 red (batch given up on while still arriving)

Suites: scene-stress (new) 28/28. Held, all green: scene-budget, overload-guard,
ingest-gate, scene-poke, vr-stats, mesh-edit-materials, dispose, diagnostics, object-sync,
net-handshake, physics-colliders, autosave-object-flows.
svelte-check 352/47 (base 352/47). npm run build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roadmap 25 section 4, audit M8. Every stamp another peer compares was that machine's own
Date.now(), so a joiner whose clock ran 90 s fast won every latest-wins merge for 90 s (a
host's LATER edit to the sky was refused on the joiner and overwritten on the host), its
flow clock and game timer ran 90 s out of phase, and its pulses arrived from the future.
The 23-A2 estimator already measured the skew and was deliberately applied to nothing.

- NEW leaf sessionClock.js (svelte/store only): the estimator moved out of musicClock,
  plus sessionNow() = the session HOST's wall clock, estimated per connection. Transitive
  (a pong carries the responder's own offset `so` and whose clock it keeps `ref`), a loop
  guard refuses a clock handed back by a peer that follows us, a 50 ms hysteresis keeps
  noise from jumping it, a gross skew (>1 s) is corrected on the first sample. Kept when
  the host departs (everyone left shares it); reset only by leaving the session.
- NEW clockSync.js: the ping/pong wire half moved out of musicClock (re-exported there
  for its callers), an immediate ping ahead of the burst, and ONE toast per peer >2 s off.
- 70 Date.now() reads in 24 modules move onto sessionNow(): every latest-wins changedAt
  (environment, scenePhysics, sceneMusic, scenePost, hudDocs + hud values, shaderGraph,
  gameState, projectManifest, audioPatch, animation docs, transport, roomanchor), the
  synced flow/animation/shader/particle clocks and module runtimeNow, game startedAt/
  pausedAt/elapsed, atscene + sceneadopt at, peerVars sentAt, sharedLibrary row stamps,
  device note stamps and audioTimeFor. Local timing (debounces, TTLs, retries) stays put.
- A JUMP IS NOT FREE: a joiner records its trigger-history epoch and every action node's
  first-seen time during the handshake, before its first pong. A -90 s correction left
  them 90 s in the future, so every live pulse would be refused as stale for a minute and
  a half. onSessionClockJump shifts them by the jump, and the handshake now sends the
  clock ping second, ahead of the full-state requests.
- cloudHooks ALWAYS_ALLOWED += clockping, clockpong. Wire additive: an older pong has no
  so/ref and reads as a raw clock; an older peer ignores the fields.

Counterfactuals (suite session-clock, C's Date.now pushed +90 s):
- adoption disabled (reconsider returns): 7 red - C's clock never lands, offset 0,
  sessionNow 90000 apart, C keeps its own EARLIER sky, flow time and game elapsed 90 s apart.
- environment commit back on Date.now: red "C takes A's later edit" (sunset kept).
- gameState startedAt/elapsed back on Date.now: red "round's elapsed agrees" (90.000 s).
- moduleSDK runtimeNow back on Date.now: red "synced flow time agrees" (90.000 s).
- jump listener removed: red "a 30 s correction moves the epoch" (0.000 for 29.998).
- jump listener removed AND the ping moved back to the end of the handshake: red "epoch
  not in the future" (epoch 20260.98 vs now 20178.90) - the hazard is real; with the
  listener restored and the late ping the same check is green (listener alone suffices).
- SKEW_TOAST_MS raised: red on both skew toasts.
- clockping/clockpong off the floor: red floor check.
- resetSessionClock call removed from resetSession: red "leaving hands C its clock back".
- pong so/ref removed: red "a pong carries the responder's session offset".

Suites vs base (this worktree): session-clock NEW 26/26; unit sessionClock NEW 14/14 (all
unit 112/112); approval-timeout 17=17, connect-states 28=28, music-clock 61=61,
net-handshake 9=9, trigger-log-sync 56 green, game-state 45 green. svelte-check 352/47 =
baseline. npm run build green (server stopped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling, and the rig measures presence

Roadmap 25 section 3d, and the N=4 regression 27-I's brief asked for.

- tests/e2e/localSignal.cjs: the local `peer` server on :9001 (extracted from the rig),
  REUSED when one already answers (the port is machine-wide), plus LOCAL_PEER_STORAGE =
  peerServerConfig {mode:'local'}. Seeding the pages is what keeps them off production;
  the old "APP_URL must be localhost" check is now "must resolve to this machine", so a
  lane serving theprototype.app via /etc/hosts can run the rig.
- net-stress.test.cjs: FOUR peers on the local server (was three on the shared box).
  With three, the host's `hosts` roster only ever names one other peer, so a fill that
  mishandled a longer list still passed; with four, six of the twelve links come from
  the fill alone. Checks: every ordered pair open (pair-complete), host broadcast whole
  to all three, all four blasting at once (12/12 pairs whole, counters reset first — the
  running-maximum trap), fan-out bounded, and NEW: the presence stream while all four
  orbit, stated as messages/s per sender against a premise that every sender drew well
  above the 20/s gate (so a per-frame sender would be visible), plus long tasks.
- net-stress.cjs: default sizes 8,10,12,16; `--presence N` (every peer orbits for N
  seconds, each counts camera messages RECEIVED per sender); a long-tasks/min column on
  every load step; a presence table.

Measured (Radeon 890M box, ALL peers on one machine, local signaling, 20 objects):
- full mesh at 8, 10, 12 and 16 peers
- 0% loss up to 3,360 (N=8), 10,800 (N=10) and 7,920 (N=12) mesh msgs/s; 0.24% at
  15,840 (N=12); at N=16 0.07% even at 10Hz, 1.87% at 28,800 msgs/s — the box is
  saturated there (16 GPU contexts, idle 34fps, echo RTT p95 550ms)
- presence received per peer: 132/s (N=8), 169/s (N=10), 207/s (N=12), 260/s (N=16);
  0.31-0.36 messages per sender frame at 60fps, i.e. the 25-C gate holds at scale
- long tasks/min 0 at N<=12; frame drop with N is GPU/compositor contention, not the
  main thread

Counterfactuals (each broken, suite red, restored):
- mesh fill disabled (hosts -> no connectToPeer): 6 of 12 pairs missing, 6/12 pairs
  deliver under four-way load
- camera gate removed (camGapMs 0): 59.9 msgs/s per sender against 18.2 with it

Suites: net-stress.test 15/15 (was 10/10 on three peers). svelte-check 352/47 (base
352/47). npm run build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roadmap 25 section 2c. An incoming connection from the host WAS the approval signal and a
refusal had no channel: the host closes a stranger's conn before it opens. So Reject left
the joiner on "Requesting" for the whole 90 s window and then said the host "did not
answer", and a full session said exactly the same thing.

- THE ANSWER RIDES DIAL METADATA. Every dial now carries `{jr: 1}` ("I understand a join
  result"). The host answers a refusal with a short dial whose metadata is
  `{joinresult: 'denied' | 'full'}`; it arrives at the joiner's `connection` event through
  signaling, so no ICE is needed — two peers that could never open a data channel still
  hear "declined". The refusal dial is never added to the mesh, never wired, closed by the
  joiner at once and by the host after 15 s whatever happens, and its trailing
  peer-unavailable is not toasted to the host.
- ADDITIVE BOTH WAYS. A joiner without `jr` is an older build that reads ANY incoming conn
  from the host as approval, so it is never sent a refusal dial (the card records
  `hearsNo`; it keeps the old silence and its own 90 s expiry). An older host sends no
  result, and its plain dial-back is still the approval.
- The approve dial-back says it is one: metadata `joinresult: approved` and a
  `{type:'joinresult', result:'approved'}` message FIRST in its handshake. The message is
  dispatched too — a refusal on an open conn ends the request the same way — and
  `joinresult` is on cloudHooks' ALWAYS_ALLOWED floor.
- peerApproval: denyPeer(peerId, result) tells a joiner that can hear it; approvePeer past
  the hard cap refuses with `full` (the VR panel's yes used to approve straight past the
  cap); applyJoinRefusal ends the request like a cancel and toasts "AB12 declined your
  connection request." or "AB12's session is full (16 people)." with Try again.
- connectionState: isRefusal / joinRefusal store (cleared by the next dial, a dismiss, 20 s,
  or leaving). Connect: a chip beside the idle pill — red "AB12 declined", amber "AB12's
  session is full (16)". Toasts (the host card): Reject goes through the shared denyPeer,
  Approve through approveDialBack, and at the cap the card offers "Tell them it's full"
  while Approve stays disabled (27-E).
- The autoaccept path (a cloud auth provider) refuses with `full` past the cap instead of
  auto-approving a joiner that can hear it.
- svelte-check: handleConnection gained `@this {any}`, which also clears 11 pre-existing
  implicit-this errors: 352 -> 341, baseline ratcheted with --update.

Counterfactuals (suite join-result):
- the refusal branch in handleConnection disabled: red - the refusal dial is wired and
  adopted, the whitelist row stays, a session host is set, no "declined" toast or chip, a
  full room is not recorded, the real two-peer Reject is not heard.
- joinresult removed from the floor: red floor check.
- the cap check in peerApproval.approvePeer removed: red "the shared approve refuses past
  the cap" (it dialled joinresult: approved).
- the handshake's joinresult message removed: red "its handshake OPENS with joinresult".
- the "Tell them it's full" button hidden: red at-cap card checks and the real-peer full.
- denyPeer's hearsNo gate removed: red "an older joiner is NOT dialled" (1 dial).
- the joinresult dispatch branch removed: red "a joinresult message ends the request".
- the Connect chip removed: red on both chip checks and the real-peer pill check.
- `jr` removed from dialOptions: red "the joiner's dial says it can hear", and the real
  two-peer Reject is not heard (the host sees a card that cannot hear a refusal).

Suites vs base (this worktree, PASS lines): join-result NEW 41/41 (incl. a real two-peer
Reject and full over the self-hosted box); session-clock 26=26; approval-timeout 17=17,
connect-states 28=28, net-handshake 9=9, connect-decision 46=46, controls-roster 108=108,
vr-peer-approve 8 green. Unit 112/112. svelte-check 341/47 (baseline 352/47, ratcheted).
npm run build green (server stopped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and a joiner stops starving its own download

Roadmap 26 section 4, Stage 1, steered by what 26-E measured.

- src/lib/qualityGovernorCore.js (PURE, import-free): the decision rule. p95 over 2s
  above the trigger (or >2 long tasks in 5s) on a HEAVY scene takes one step, held 3s;
  10s of p95 under 20ms walks one back. A step up within 20s of a walk down doubles the
  next recovery hold (flapping), capped at 80s. A 600ms settle window after every change,
  because the change itself is a hitch (a shadow toggle recompiles every lit material).
- src/lib/qualityGovernor.js: the wiring. Frames from sceneBudget's loop, published as a
  LOCAL qualityOverrides store every consumer reads; never writes a preference, a
  document or a message. Hidden tab / 26-G pause = no evidence.
- THE STEP ORDER IS THE MEASUREMENT'S, not the roadmap's: shadows first (the shadow pass
  is the second copy of every mesh; calls, not fill, bind a many-object scene), then
  resolution 85/72%, AO, 61%, the post stack, 50%, the particle cap (Stage 3's third
  bullet), the presence send gap. Consumers: lightParams + environment through one
  `shadowsDisabled()` (environment re-asserted the saved preference on every apply and
  undid the override within a frame — found by the suite), threlte's own dpr (Scene),
  AO/post filtered in Outline and the composer re-sized on a dpr change, particleRuntime,
  Scene's camera gap.
- The desktop trigger is 35ms, not 33: frames are vsync-quantised, so a steady 30fps
  reads 33.3-33.4ms and a 33ms trigger would walk it to the bottom of the ladder.
- Light scenes are never governed (the 26-G ruling: a slow machine is not an overloaded
  scene), which also keeps SwiftShader suites untouched.
- NOT FIGHTING 26-G: the first step records the size readings (qualityBaseline) and
  sceneIsHeavy judges by the larger of now and then until full quality returns — else
  turning shadows off halves the calls and talks the freeze guard out of a scene that is
  still too heavy. A scene that really shrinks (<70% of the baseline objects) drops it.
- THE INGEST DRAW GAP (26-E's biggest finding): while a received batch drains through
  slow frames (backlog > 50, p95 > 20ms) the renderer draws 4 frames a second, sticky for
  the drain. MEASURED with the rig, joiner time-to-synced for 1,000 / 2,000 / 3,000 boxes:
  8.0s / 112s / ~180s before, 1.8s / 3.4s / 6.2s after, zero long tasks.
- UI: a chip beside the object count ("Reduced quality (scene is heavy)" — click to hold,
  click again for full quality with a 60s snooze), a one-time toast with the same two
  actions, and Settings > "Reduce quality when the scene is heavy" (LOCAL, default on).

Measured end to end in the suite on real frames (Radeon 890M): 3,000 real boxes engage the
governor on their own, it takes ONE step (shadows off), draw calls 5,312 -> 2,930, frame
p95 50ms -> 33.4ms, and it stops there.

Counterfactuals (each broken, red, restored):
- sceneIsHeavy ignoring the baseline: 2 red (26-G no longer judges heavy; no pause)
- environment re-asserting shadowMap.enabled: 2 red (shadows stay on; real calls
  5,312 -> 5,310)
- Outline's composer not following dpr: 1 red (composer buffer 1280 -> 1280)
- the draw gap early return removed: 1 red (780 render calls/s against 780)
- desktop trigger back to 33ms: unit red (steady 30fps read as overloaded)
- settle window 0: unit red (the recompile's long tasks take a second step)

Suites: perf-governor (new) 39/39; unit qualityGovernor (new) 21, all unit 119/119.
Held, green: scene-stress, overload-guard, scene-budget, ingest-gate, scene-poke,
object-sync, view-mode, shadows, environment, environment-v2, env-preset-broadcast,
scene-post, scene-post-ui, post-play-mode, particles, flow-particle, net-stress,
camera-pip, settings-toasts-ux, settings-labels; net-handshake red once on a two-peer
join then green on re-run. scene-post-effects 4.5 ("assigning a LUT PUSHES its bytes")
is red IDENTICALLY with this diff reverted to HEAD — pre-existing, not chased.
svelte-check 352/47 (base 352/47). npm run build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hardening wave 2b — roadmap 26: the overload gateway, from one poke per frame to a window that pauses instead of freezing
Hardening wave 3a — roadmap 25: one clock for the session, and a joiner that is told why it was refused
AlexZ005 and others added 2 commits September 17, 2026 22:35
Hardening wave 3b — roadmap 26: the stress rig, four-peer net-stress, and a governor that drops shadows before frames
…d gone stale

- CHANGELOG: a new "## 1.12.0 - Hold together" section. Wave 1's entries were
  sitting UNDER the shipped 1.11.0 heading: the lane wrote them into "## Unreleased"
  while it was branched off 1.10.0, and the 1.11.0 bump renamed that heading around
  them. Moved them back out and added the sections for wave 2a (storage), wave 2b
  (the overload gateway) and wave 3 (one session clock, joinresult, the governor).
- RELEASING.md: the svelte-check gate no longer "lives in the workflow" - 27-I moved
  it to check-baseline.json, read only by scripts/check-ratchet.cjs, which both
  release.yml and ci.yml call. The old sentence would have sent the next releaser
  editing a number that is not there any more.
- RELEASING.md: a new step for recreating release/next after the tag push.
  delete_branch_on_merge is on, so merging the release PR deletes release/next AND
  retargets every open PR based on it to main - which nearly put an ungated batch
  straight on main during 1.11.0. Recording it as a step, per that release's
  follow-up 2, also keeps release/next's version from drifting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit e3fb490 into main Sep 17, 2026
8 checks passed
@AlexZ005
AlexZ005 deleted the release/next branch September 17, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant