Skip to content

v1.11 Risk & Reliability: one outage model, three sources - #17

Merged
NeverEndingCode merged 14 commits into
mainfrom
v1.11-risk-reliability
Aug 9, 2026
Merged

v1.11 Risk & Reliability: one outage model, three sources#17
NeverEndingCode merged 14 commits into
mainfrom
v1.11-risk-reliability

Conversation

@NeverEndingCode

Copy link
Copy Markdown
Owner

Implements docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md via docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md. Eleven tasks, one commit each; execution log in ...-notes.md.

What this is

Hazards, scheduled Grid maintenance and the reworked overheat penalty are not three systems — they are one system with three sources. Everything is an outage: { id, kind, scope, factor, startAt, endAt, source } in state.server.outages. That single representation is why the UI can tell one coherent story about a slowdown instead of reconciling two lists.

The two things worth reviewing closely

1. The integral (shared/outages.js). Within an evaluation window there are no player actions, so production is piecewise-constant and has a closed form. evaluate() stays one multiplication per lane — no sub-stepping. It is exact, and tests/outages.test.js cross-checks it against a brute-force integral specifically so a future "simplification" into sampling fails loudly.

2. The Overclock conversion (shared/gameRules.js). The lane stops producing and becomes boost = 1 + gain * ocOutput / racksOutput. At the shipped gain of 1 that is algebraically racks + oc, so goalCtx.totalOutputPerSec is unchanged on deploy. This is what made the spec's "highest-risk edit" survivable: contracts, achievements, streak and reducer.economy needed zero edits. Only one goals test changed, and only because its premise — that Overclock nodes with no racks produce output — is exactly what the rework removes.

If a future change makes those four suites move, the conversion has drifted from output-neutral. Check overclockBoost() before touching a test.

Deliberate calls a reviewer should push back on if they disagree

  • effectiveFactor(outages, lane, index, from, to) rather than the spec §4 sketch's (outages, scope, …) — every call site loops over a lane's indices.
  • The overclock boost is not degraded by outages: ransomware's {lane:'*'} already covers the Racks lane it multiplies, so applying it to both would square the penalty.
  • goalCtx deliberately ignores outages — it reports installed capacity, so a hazard cannot make a contract easier or a supply cheaper.
  • The offline cap samples the whole absence (spec decision 5). Odd on purpose, and commented at the call site; the literal first-N-hours reading was considered and rejected.
  • meta.supplies is the one stored value that decreases. The decision-1 property test excludes it by name.

Two bugs the tests caught during implementation

  • activateDueMaintenance skipped a window whose endAt had passed as "missed" — but a window covering the whole evaluation gap ends exactly at now, so that paid the player in full for time they were down.
  • legacyFreeze was gated on the shutdown toggle, but overheatOutage falls back to setting heatCooldownUntil when there is no owned rack to down — so the cooldown was set and never honoured, and heat re-crossed the cap every evaluation.

Verification

Gate Result
SQLite (TEST_BACKEND=sqlite vitest run) 793 passed, 29 skipped
Postgres (npm run test:all) 819 passed, 3 skipped
npm run smoke (every suite) 66 PASS, 0 FAIL
cd client && npm run build clean

Two pre-existing smoke checks asserted behaviour this release deliberately changes and were updated rather than worked around (smoke-v12 overheat lockout, smoke-v16 onboarding step count).

Not done here

Merge, then tag main (never the branch) as v1.11.0 and push the tag — the tag push is what triggers the GHCR publish.

🤖 Generated with Claude Code

Evan Phyillaier and others added 14 commits August 8, 2026 18:24
Approved brainstorm covering backlog items B (hazards + mitigation), C
(dynamic Grid) and D (Overclock rework). The seven owner decisions are
recorded as locked, with the reasoning, so planning does not relitigate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the approved design into an executable plan. Task order front-loads
the risk exactly as spec §13 asks: the config boolean type, then the pure
integral, then evaluation wiring that proves zero outages changes nothing,
then each source in turn, with the breaking Overclock rework deliberately
late and the goals suite as its net.

Two things worth flagging for whoever executes it:

- The Overclock conversion is defined as a RATIO of the Racks lane
  (1 + gain * ocOutput/racksOutput). At the default gain of 1 that is
  algebraically racksOutput + ocOutput, so goalCtx.totalOutputPerSec is
  unchanged on the deploy and the existing goals/contracts/achievements
  suites pass untouched - which is what de-risks "the highest-risk edit in
  the release". The balance pass becomes one tunable.

- effectiveFactor takes (outages, lane, index, from, to) rather than the
  spec's (outages, scope, from, to). Stated as a deviation in the plan;
  every call site is a loop over a lane's indices and the sketched
  signature would allocate a throwaway scope object per tier per
  evaluation. Semantics identical.

Every signature in the plan was re-verified against this branch first
(req.user.sub, the `tiers` lane name, scheduleAnomaly's shape), and the
integral, the derivation hash, the conversion algebra and the cure's
price floor were all checked numerically before the plan was committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validateConfig accepted only numbers, so an admin toggle had nowhere to
live. TUNABLES descriptors now take type: 'boolean' (existing rows are
numeric by default, so nothing else changes), validateConfig enforces the
type in both directions, and upgradeConfig copies booleans through.

Event modifiers stay numeric-only: mergeEventModifiers would happily
setAtPath a number onto a boolean path and the merged doc would then fail
validateConfig with a confusing error, so validateModifiers rejects boolean
paths up front and AdminEvents drops them from the picker (whose min/max
validation would read undefined on them regardless). An event can turn the
risk system up; it cannot flip its switches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One object covers all three sources. The integral has a closed form because
within an evaluation window there are no player actions, so the only thing
that varies is which outages are active and each is a constant factor over
an interval - collect the boundaries, multiply the covering factors per
sub-interval, weight by length. Exact, and evaluate() stays one
multiplication per lane rather than stepping the simulation.

Cold Storage is excluded structurally, in OUTAGE_LANES, rather than by each
caller remembering to skip it - a wildcard scope means every ACTIVE lane.

The brute-force cross-check in the test file is deliberate: it is the thing
that would catch a future 'simplification' into sampling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the integral into both branches with nothing yet creating an outage,
so the interesting assertion is the negative one: with zero outages,
production is bit-identical to today and every pre-existing evaluate test
passes untouched.

The offline branch carries the decision-5 comment at the call site. The
factor is computed over the WHOLE absence and applied to the CAPPED payout,
so an incident covering 2 of 12 absent hours costs 2/12ths of what was
credited. That reads wrong until you see why the literal first-N-hours
reading was rejected: at one incident per six hours most would land in
unpaid time and cost nothing, gutting the system for the players it should
reach most.

Pruning happens after the integral, not before - an outage that ended
mid-window still degraded the part it covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…them in evaluate()

The client runs evaluate() optimistically against this same code, so a
Math.random() at evaluation time would make the two sides disagree about
what happened during an absence. Identity, target and duration are
therefore derived from the scheduled timestamp via a pure hash - only WHEN
the next one happens uses an injected rng, which is safe precisely because
that time is never displayed and is overwritten by the authoritative state
on the next reconcile.

Two easy-to-get-wrong details are load-bearing: the next hazard is
scheduled from the FIRE time (scheduling from the evaluation's "now" would
make a long absence produce exactly one hazard however long it was), and
MAX_HAZARDS_PER_EVALUATION bounds the loop so a save with a 1970
nextHazardAt reschedules instead of spinning.

Deviation from the plan: absorbWithSupply is implemented here rather than
landing as a stub in Task 4 and being replaced in Task 5. It is inert until
meta.supplies exists (the !bag guard), so Task 4's tests are unaffected and
the seam never needs rewriting.

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

Supplies are bought with credits but stored in meta. That split is the
point: credits are the run currency, so this is a sink for what players
have most of, and meta survives Migrate, which gives a genuine reason to
spend down before prestiging rather than watching the balance evaporate.

Absorption happens at fire time inside evaluate(), so it reaches a hazard
that starts and ends during a nine-hour absence - the only defence that
can. It is also the one place in the release that decrements a stored
value, which is not a decision-1 violation: a supply is a consumable
bought for exactly this purpose. The decision-1 property test in Task 9
excludes meta.supplies by name for that reason.

Prices are seconds of current output rather than flat, matching
social.contractFlopsSeconds and batchQueue.blockFlopsSeconds - a flat price
is a meaningful sink for an hour and free forever after. The rate is read
from goalCtx, which is deliberately outage-free: pricing off a degraded
rate would make supplies cheapest exactly when an incident is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coming back to a running incident should never leave you a spectator, but
it must never be the cheap path either. The formula puts the cure's floor
at cureMultiplier times the supply that would have prevented it, and the
test asserts that as a property across every kind, output rate and elapsed
fraction rather than at one sample point - if the formula ever changes,
that sweep is the contract it has to satisfy.

Curing truncates endAt to now rather than splicing the outage out, so an
evaluation window straddling the cure still integrates the time the lane
was genuinely down. pruneExpired removes it on the next pass.

Maintenance and overheats are not curable: one is telegraphed rather than
misfortune, the other is the player's own doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scheduled on the server load path rather than inside evaluate(), following
the scheduleAnomaly precedent but for a sharper reason: this window is
DISPLAYED with a countdown, so if the client drew it from its own rng the
countdown would jump on every reconcile. evaluate() only activates an
already-scheduled window, which needs no derivation and is deterministic on
both sides.

Fixes a bug the test caught while being written. activateDueMaintenance
originally skipped a window whose endAt had already passed, reasoning it was
"missed entirely" - but a window covering the whole evaluation gap ends
exactly at now, so that guard paid the player in full for time they were
demonstrably down. The guard cannot see lastEvaluatedAt, so it cannot make
that call correctly; the integral can, and already ignores anything ending
before the window starts. Removed it and left the reasoning in a comment.

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

The lane's output is now expressed as a fraction of the Racks lane's:
boost = 1 + gain * overclockOutput / racksOutput. At the default gain of 1
that is algebraically racks + overclock, so goalCtx.totalOutputPerSec is
unchanged across the deploy. That is what makes this survivable: contracts,
achievements, streak and reducer.economy needed zero edits, and the balance
pass is one tunable rather than a re-costing exercise.

One goals test did change, and its premise is exactly what the rework
removes: it asserted that overclock nodes with NO racks produce output.
They no longer do - there is nothing to amplify. Rewritten to assert the
new contract, plus a companion test pinning the nothing-to-amplify case.

The boost is deliberately not degraded by outages. Ransomware's wildcard
scope already covers the Racks lane the boost multiplies, so applying it to
both would square the penalty.

Overheating now downs one rack tier, its victim derived from the timestamp
so two clients reconciling the same overheat agree. legacyFreeze is
deliberately NOT gated on the toggle: overheatOutage falls back to the old
cooldown when there is no owned rack to down, and a cooldown that is set but
not honoured would let heat re-cross the cap on every evaluation. Gating it
that way also keeps the condition identical to goalCtx's, so the displayed
rate and the produced rate cannot disagree.

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

This is a live game with real players, so risk.enabled has to be a true
kill switch rather than a pause. Clearing happens before the integral, which
means even the window being evaluated right now is paid in full - a player
mid-ransomware when the owner flips the switch is visibly un-broken on their
next reconcile rather than left throttled by a system that no longer exists.

Decision 1 is now enforced as a property across a 60-seed randomised sweep
rather than by inspection: no hazard may reduce credits, wafers, tapes,
lifetime output or any owned count. meta.supplies is excluded by design and
by name - it is the one consumable bought expressly to be spent. That test
is the guardrail that stops a later small change reintroducing asset loss.

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

The strip reads server.outages directly, which is why it can tell one
coherent story about a slowdown rather than reconciling two - there is only
ever one list. It also carries the upcoming maintenance window, the single
thing in this release the player is allowed to see coming.

A new Resilience tab gives supplies, the standing risk rate and any running
incident one home. The rate is derived from config and the next hazard time
is never rendered anywhere: showing it would collapse the prepaid economy
into buying one licence twenty minutes beforehand.

The Racks panel now labels a downed tier with its reason. A tier silently
producing nothing reads as a bug, and after this release that will happen
routinely.

Notices reuse the existing toast rather than adding a notification system,
following the v1.10 rule that rewards use the modal and everything else uses
the toast. The absorbed notice is the load-bearing one.

Per the standing tour obligation, the two new steps are appended to
onboarding.js rather than registered as their own tour, so onboarding stays
a strict superset. tests/tours.test.js hardcodes step counts; both moved by
exactly 2, which is the check that the steps are ungated - a fresh save can
be hit by a hazard, so it must be told how to prepare.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine end-to-end checks over a real server: an outage costing output, Cold
Storage provably untouched beside it, buying and refusing a supply, the
firing bound terminating on a 1970 nextHazardAt, offline absorption, the
kill switch clearing a live incident, the boolean tunable type rejected end
to end, and the Resilience tab rendering in a browser.

Two pre-existing smoke checks asserted behaviour this release deliberately
changes, and were updated rather than worked around:

- smoke-v12's overheat check expected heatCooldownUntil and the frozen-lane
  messaging. The penalty moved to the Racks lane, so it now asserts the
  overheat outage and that the outage strip names it. Asserted on the strip
  rather than the Racks panel because the strip is in the sticky header and
  is visible whatever tab is open and whichever tier was picked - including
  one past unlockedUpTo, which is what made the first attempt flaky.
- smoke-v16 hardcoded the onboarding step count, 11 -> 13 for the two
  ungated Resilience steps.

Verified: SQLite 793 passing, Postgres 819 passing, 66 smoke checks across
every suite, client builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NeverEndingCode
NeverEndingCode marked this pull request as ready for review August 9, 2026 03:11
@NeverEndingCode
NeverEndingCode merged commit aa9df09 into main Aug 9, 2026
4 checks passed
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