Skip to content

Frontend & Render Defences — ADR-116, ADR-117, ADR-118

Frontend & Render Defences — ADR-116, ADR-117, ADR-118

Three rulings from one adversarial review (2026-08-08) of four proposals against §6.1, §6.4, §9.5, §9.6.2, §10.2 and §12.2. Four proposals produced three ADRs and one section refinement, and the arithmetic is the point: one proposal was already the architecture, one rested on a cost line the corpus records as zero, and the most valuable outcome was a gap none of them set out to find.

Numbering: 115 was the highest allocated. The event-bus ruling takes 116 by owner instruction; the Edge Fetch and OPFS rulings follow as 117 and 118.


ADR-116 — The Event Bus Is a Second Subscription Surface, and Visibility Filtering Must Reach It

Status: Accepted · Date: 2026-08-08 · Amends 010 (the Zero-DOM sandbox’s disclosure contract now names the event path explicitly) · Depends-on 086 (a plugin’s interest set is bounded by its admitting principal’s disclosure set — this ADR extends that bound to a surface it did not reach) · Depends-on 094 (opposed-roll disclosure is a GM-delegated, room-scoped policy, and the bus must not widen it) · interacts with 034, 040, 101

Context

This gap was not proposed; it was found while reviewing a modding proposal that turned out to be ~80% the existing architecture. That is worth recording, because the review’s value came from the part nobody was asking about.

§9.5 Guardrail 5 delivers plugin state as a host-published, per-frame immutable snapshot of “the entities the plugin has declared interest in”, and ADR-086 bounds that interest set by the disclosure set of the principal who admitted the plugin. Both mechanisms concern the snapshot.

§12.2’s descent.events is a different surface. It was one sentence — “provides global subscription hooks to game state changes” — and nothing in the corpus bound it to §8.2’s Visibility Channels. The word global was doing real damage: read literally, a plugin admitted by a player subscribes to state changes irrespective of what that player may see.

The failure this closes

A plugin installed by an ordinary player receives combat:turnStart naming an entity the GM has not disclosed, or a descent.dice event carrying a Keeper’s secret roll. No sandbox boundary is crossed and no rule is broken — the plugin is behaving exactly as documented, the QuickJS isolation is intact, and the disclosure happens through a convenience API working as designed. That is the shape §8.2 exists to prevent and the shape ADR-086 was written for; it simply did not reach here.

Decision

  1. Every event is filtered against the admitting principal’s disclosure set, evaluated through §8.2’s Visibility Channels at the tick the event is raised — not at delivery, and not against the room. Same authority, same evaluation point as the snapshot, so there is one visibility model rather than two that agree today.
  2. An event naming an undisclosed entity is DROPPED, never redacted. A redacted event still discloses that something happened, which is the fact worth hiding — the same reasoning by which ADR-034 refuses “present-but-culled” and §8.2 refuses an unfiltered snapshot with fields blanked.
  3. Absence is uninformative: there is no “you missed an event” signal, no gap counter, and no sequence number a plugin can difference. ADR-101 records why for the announcement stream — a schedule that advances regardless of content is what makes silence carry nothing — and an event bus that reported its own omissions would rebuild the channel it just closed.
  4. descent.dice results obey ADR-094 unchanged. A plugin sees the net outcome its principal sees. An opposition’s rating, threshold or unresolved roll never reaches the bus, and declassification remains the keeper authority ADR-094 defines rather than something a plugin can request.

Alternatives Considered and Why Rejected

  • Redact undisclosed fields and deliver the event anyway. Rejected by clause 2: the event’s existence is itself the disclosure. This is the most likely re-proposal because it looks more useful to plugin authors.
  • Filter at the SDK boundary in the plugin worker. Rejected: it puts a disclosure decision on the client side of the trust boundary, which is ADR-034’s rejected client-side culling with a different name.
  • Rely on ADR-086 as already covering it. Rejected on the text: ADR-086 binds the interest set, and the bus is a separate subscription. Two mechanisms that “obviously” compose are exactly what the corpus records as its most common defect class.
  • Give plugins a gap counter so authors can detect filtering and degrade gracefully. Rejected: it is a covert channel with a developer-experience justification.

Consequences (including negative)

  • A plugin cannot distinguish “nothing happened” from “something happened that you may not see”, and this will produce confusing authoring experiences. That is the intended trade and it is the same one every disclosure rule in this corpus makes; the SDK dev server (§12.1) should be able to simulate a restricted principal so an author meets it at authoring time rather than in a player’s session.
  • Filtering runs per event per subscribed plugin, so cost scales with (event rate × plugin count), on top of the snapshot cost Guardrail 5 already bounds. It belongs to the same per-frame aggregate ceiling rather than a new budget.
  • This does not make plugins safe to admit broadly. It closes one channel; ADR-086’s bound and Guardrail 5’s budgets are unchanged and still carry the rest.

Enforcement

  • The §14.7 generated disclosure tests gain the bus as a subject: for a principal set with a known disclosure set, assert that no event naming an entity outside it is delivered — the same generator that already asserts revealed-set containment.
  • An architecture test asserts the event path resolves through the same visibility evaluation as the per-frame snapshot rather than a second implementation, because two implementations of one security property is how the accessibility path acquired ADR-102.
  • Not enforceable and stated as such: nothing prevents a plugin inferring disclosure from timing of the events it does receive. That is the residual ADR-101 names for its own stream, and it is bounded here by the same tick-relative scheduling rather than solved.

ADR-117 — The Edge Fetch Service Is Restricted to GMs, Not Removed

Status: Accepted · Date: 2026-08-08 · Supersedes 039 · Amends 010, 015 (inherited from the superseded record) · interacts with 006, 015

Context

The proposal was full deprecation: ban external URLs, require download-then-upload, serve only from our own CDN. The security instinct is sound — §6.1’s fetcher carries the most intricate control surface in the chapter (resolved-address admission, DNS-rebinding-safe connect, per-redirect re-checks, byte ceilings, quarantined decode, generic error reporting) — and deleting a control that complex genuinely removes a class of latent defect.

Three findings moved the disposition.

The findings

  1. Removal does not remove the trust tier. §6.4 classifies P0–P3 by what executes and P4 by what enters. Uploaded bytes are player-supplied bytes: image bombs, decompression bombs and the decoded-pixel-count limits of §6.2 apply identically. What removal deletes is P4’s SSRF face, not P4.
  2. Removal consolidates onto the weaker chokepoint. §10.3 face 5 exists because the fetcher bypasses §6.2’s presigned-upload quotas. Route everything through upload and face 5 folds into face 1 — the one §6.2 flags as harder, since “storage cost is incurred before any RoomGrain sees the file”, so a Grain token bucket cannot help.
  3. The cost argument is not supported by §10.2. Asset egress is recorded at $0 (Cloudflare R2). The saving available from removal is one small always-on deployment unit, not bandwidth. Recorded because “it saves egress” is the intuitive justification and it is false here.

And the exposure that was real is a population, not a mechanism: §6.4’s P4 row read “any player”, and the row’s own note explains that this is why it carries the strongest network isolation of any tier. Map images are authored by the person building the map.

Decision

  1. Every control in ADR-039 is retained unchanged. Isolation outside the VNet, no managed identity, resolved-address admission re-checked per redirect, connect-to-the- checked-address, byte and rate ceilings, quarantined decode, never-verbatim response, single generic failure reason. This ADR narrows who may invoke the service and changes nothing about what it does.
  2. Invocation is a §9.2 keeper authority, checked at the authority tier rather than in the transport — the reason §9.2 already gives for occluder authoring: a transport check is bypassed by the next caller, and there will be another caller.
  3. P4 is re-parented to this record and survives, covering uploaded bytes whatever happens to the fetcher.
  4. Per-account token buckets remain and are now per-GM, which lowers the aggregate ceiling without loosening any per-principal bound.

Alternatives Considered and Why Rejected

  • Full deprecation. Rejected on findings 1–3, plus a UX cost that lands worst on C-Companion (§9.7): “download it locally, then upload it” is several steps through a mobile file picker for what is one paste today. This will be re-proposed — the security argument for it is genuinely good, and the reasons it loses are not obvious.
  • Domain allowlisting instead of principal restriction. Rejected: §6.1 already records that hostname-based controls are defeated by IP literals and inward-pointing DNS, and an allowlist is a hostname control with a curation burden.
  • Keep it open to all players with tighter quotas. Rejected: quotas bound cost, not the SSRF surface, and the surface is what the isolation exists for.

Consequences (including negative)

  • A player who wants their own avatar from a URL must now ask a GM or upload a file. A real UX loss for the most common player-side use of the feature, accepted because the upload path remains open to them.
  • ADR-039’s diagnosis is retained, not retired. Its SSRF analysis is the reason a fetcher may never be reintroduced casually, and it survives supersession intact — the rule this corpus applies to all five of its retired decisions.
  • The control surface is not reduced. This ADR buys exposure reduction and pays nothing in complexity: every line of §6.1’s fetcher still has to be maintained and tested. A future decision to remove it entirely is not foreclosed by this one.

Enforcement

  • The import endpoint sits in the keeper-authority route group whose filter §9.2 already governs, so a route added without it fails the existing architecture test rather than a new one.
  • The §6.1 controls keep their existing tests unchanged; this ADR adds no assertion about the fetcher’s behaviour because it changes none of it.

ADR-118 — OPFS Durability Is Requested, Not Policed

Status: Accepted · Date: 2026-08-08 · interacts with 054, 060, 079, 087, 105

Context

The proposal was a two-part defence of “CDN egress spikes” caused by players clearing browser storage: a lobby warning telling players not to clear their cache, and throttling accounts that re-download a bundle repeatedly.

The premise does not survive §10.2, which records asset egress fees at $0/month (Cloudflare R2) and annotates storage as “distinct from egress, which is genuinely $0”. There is no egress spike to defend against. The plausible residual costs are R2 Class B operations and origin CPU, and neither is registered as a quantity, so under P5 no control can be sized against them yet.

Meanwhile the proposal addressed only voluntary eviction. The involuntary path — a browser reclaiming evictable storage under pressure — is the one nothing in the corpus addressed, and it is the more likely cause.

Decision

  1. The client requests durability: navigator.storage.persist() on first campaign load, and a StorageManager.estimate() pre-flight before a large load that surfaces a real “this campaign needs ~N GB, you have ~M” figure.
  2. A persistence grant is a request the browser may refuse, and this is stated rather than assumed: the mechanism reduces involuntary eviction and cannot prevent it. The recovery path is an ordinary re-download, which is the same path as today.
  3. No UI discourages clearing browser data. ADR-054 records that clearing site data is the only escape from a stale-Service-Worker lockout; a warning against it protects the cache at the cost of the user’s only recovery.
  4. No re-download throttling. Rejected on three independent grounds below.

Alternatives Considered and Why Rejected

  • A lobby warning (“do not clear your browser cache”). Rejected by clause 3. It is also unenforceable and addresses the smaller half of the problem.
  • Throttling repeat downloads of the same bundle. Rejected three times over: it defends a $0 cost line; the abuse signal is indistinguishable from a GM prepping across devices, a device reset or a browser update, which is the compromise-encoded-in-a- constant trap ADR-087 identified and ADR-105 resolved by splitting bounds that do different jobs; and per-account per-bundle download counts are new retained behavioural data, owing an ADR-079 rights-holder answer and an ADR-078 erasure path. Creating a retention obligation to defend a cost line that does not exist is the worst trade available.
  • Throttling key issuance instead of bytes. Rejected and worth naming separately, because it looks equivalent and is not: under ADR-060 OPFS stores ciphertext only and keys are session-scoped, so throttling key issuance throttles play, and revocation depends on that same path.

Consequences (including negative)

  • A refused persistence grant leaves the original behaviour, so this improves the common case and guarantees nothing. Stated because “we made the cache persistent” is how it will be remembered otherwise.
  • estimate() is advisory and imprecise — browsers report quota in deliberately fuzzy terms — so the pre-flight informs and must not gate a load on an arithmetic comparison.
  • The cost question is deferred, not answered. If re-download volume ever matters, the work is to measure Class B operations and origin CPU and register a quantity, and only then design a control. That ordering is the point.

Enforcement

  • A unit test asserts the persistence request is issued once per origin and that a refusal is a logged, non-fatal outcome rather than an error path.
  • A release-checklist item asks whether any new control has been added that counts per-account asset access; if one has, ADR-079’s question is due.
  • Nothing enforces the absence of a warning banner, which is the honest position: a future UI change could reintroduce it, and only review catches that.