The Capability Veto, Made Concrete — ADR-124
The Capability Veto, Made Concrete — ADR-124
One ruling from Phase 3’s first frontend workstream (2026-08-08). ADR-064 decided that a test-only capability veto is a first-class component and that it is compiled out of production builds. It did not decide the shape of the injection point, and
docs/README.mdrecorded that shape as the one remaining item genuinely blocked on project code. The frontend skeleton now exists, so the item is dischargeable, and this record discharges it.Two of the three mechanisms rejected below were rejected on evidence produced while writing the code, not on argument alone — including one that had already been written, was green, and was proved partial by mutating the tree. Those measurements are recorded here rather than in a commit message, because the wrong one is the one a future reader would otherwise re-propose.
ADR-124 — The Capability Veto Is Compiled Out by Construction, Not by a Flag or a Mock
Status: Accepted · Date: 2026-08-08 · Depends-on 064 (the compiled-out veto and executable-matrix seam whose injection-point shape this settles) · interacts with 027, 052, 063, 123
Context
ADR-064 committed to three things: a single injection point that resolves every capability, a veto that can force any of them absent or degraded per automated test and per manual QA session, and the rule that the veto is compiled out of production builds, and this is not optional — because every path it reaches is a downgrade, so a surviving runtime override is a supported mechanism for disabling controls.
What it left open is how. Three mechanisms were available and they are not variations of one another: a mocking library applied per test, runtime feature flags read from configuration, and compile-time elimination. The choice determines whether the last of ADR-064’s three commitments is a property of the build or a promise in a document.
The deadline is ADR-064’s own negative consequence: “the moment any code first asks ‘is WebGPU
available’, the answer must come from this injection point. The cost of retrofitting is finding
every capability check ever written.” At the time of this ruling there was not one raw
capability probe anywhere in apps/vtt-frontend-client/src/, which is the only moment at which
the seam costs nothing. That fact is the reason this is being decided now rather than with the
renderer.
Decision
-
The injection point is a frozen record resolved once, before the first component runs.
createCapabilities()probes in its constructor and returns a frozen object; detection happens in the composition root, not inside a component. §9.5 requires the Session Capability Report to describe one session, and an answer that settles a tick after the first render is an answer some component already asked for and got wrong. -
The veto is a constructor argument. There is no installer and no module-level mutable state. This is the clause that carries the security argument, and the rejected alternative is the intuitive one — an exported
installVeto()writing a slot read behindimport.meta.env.DEV. The two leak differently: a surviving installer can mutate the record the running application is using; a surviving constructor parameter can at worst build a second record nothing consults. The injection point therefore contains noimport.meta.envread at all. -
The veto’s session half is reached only through a dynamic
import()inside a staticimport.meta.env.DEVblock, holding the only textual reference to it in the app graph. This is not a new pattern; it is the onesrc/testing/e2e-harness.tsalready documents, and the lesson it paid for is the operative one — a static flag is not a tree-shake. That harness was gated onimport.meta.env.DEV, was genuinely unreachable, and shipped anyway, because a top-level import let live closures read its exports. Elimination is a property of the call site. -
Two enforcement checks, kept because they fail differently. An exact module-graph gate inside
vite buildfails the build if a forbidden module is in any production chunk; a string scan over the whole ofdist/runs afterwards. The plugin is exact and cannot observe its own removal from the plugin list. The scan is inexact and can. Neither subsumes the other, and a test asserts they name the same module so the pair cannot drift apart. -
Raw capability globals are an ESLint error outside the injection point, from the first day rather than after the first violation.
navigator.gpu,navigator.storage,SharedArrayBuffer,crossOriginIsolated,VideoDecoder,VideoEncoder,RTCPeerConnection,indexedDB,WebTransportandFontFace.OffscreenCanvasis deliberately not on the list: the Render Worker monopolises one, so banning it would ban a use rather than a probe and would need an exemption on the day it was written. -
Guardrail 7 becomes a declaration the build reads, and a blank cell is a blank — never a skipped test. Every row is either the name of a test that forces its condition through the veto, or a recorded reason for being blank; exactly one, never both and never neither. The blank set is additionally pinned by a flat ledger asserted for exact equality, so it fails in both directions — a new blank is red, and a row demonstrated without pruning its ledger line is equally red. That is
uncited_ceiling.txt’s discipline applied to cells. -
The gate this produces blocks a release, not a build. ADR-064 upgrades the release gate from “every cell has text” to “every cell has a passing test”. Two of sixteen rows are demonstrated. A red build on the fourteen unbuilt subsystems would make the suite unrunnable for the whole of Phase 3 and would be silenced within a week, which is how a gate becomes a baseline file nobody reads.
Alternatives Considered and Why Rejected
-
A mocking library, applied per test. Rejected, and ADR-064 already rejected it in principle: it scatters capability checks with no single source of truth and cannot guarantee every check point is covered. Two further reasons emerged in the writing. A mock proves nothing about production, where the question is whether an override mechanism exists — and a mocking framework’s answer is “yes, everywhere”. And §14.6 requires the same veto to work in a manual QA session, which has a URL and no test runner.
-
Runtime feature flags read from configuration. Rejected by ADR-064 consequence 1, restated because it is the alternative with the strongest surface appeal: it gives QA a switch without a rebuild, and it is the one an operations team would ask for. Every path the veto reaches is a downgrade — Aggressive LOD, a relaxed frame-skew budget, a capped premium tier — so shipping the switch ships an attacker’s supported route to each of them. There is also no configuration-fetch path on the client that would not itself need a capability answer first.
-
An installable global override behind
import.meta.env.DEV. Rejected by clause 2, and this is the alternative most likely to be re-proposed because it reads as equivalent and is easier to wire. It is not equivalent: it leaves a function that mutates the live record. -
A build-time environment flag (
VITE_ENABLE_VETO) instead ofimport.meta.env.DEV. Rejected: it makes “did the veto ship” a property of an environment variable at build time, which is invisible in the artefact and in review.DEVis substituted by the bundler and the result is checkable ondist/. -
it.skipfor the fourteen undemonstrated rows. Rejected by clause 6. A skip prints a yellow line that a reader parses as “covered, temporarily off”, a runner summarises it beside the passes, and its text is a test name — describing what would be asserted rather than why nothing is. Fourteen skips read as a suite with fourteen small gaps; fourteen blanks read as a release gate that is closed. ADR-064’s entire subject is the difference between those two appearances. -
A
dist/scan anchored on the veto’s export names. Rejected by measurement, after it had been written and was green. Re-introducing a staticimport { describeVeto }intoApp.tsxput the veto’s code in the shipped chunk and the scan reported clean: a minifier mangles top-level function names, anddescribeVetowas emitted asup. The one anchor that did survive,capability veto, turned out to come from aRangeErrorincapabilities.ts— a module that ships by design — so the list could have reported a leak that was not there while missing the one that was. The anchors are now error-message fragments and an inlined literal, which minification preserves verbatim, and a test rejects any anchor that is a bare identifier. This is also why clause 4 has two checks rather than one: the exact check exists because the cheap one was proved partial, not because two seemed prudent. -
Naming the effective Rendering Profile in the capability report. Rejected for now: ADR-066 resolves C-Tactical / C-Companion from the viewport class, which is not a capability and is not in this record. A
profilefield would be a guess wearing the costume of a resolution. §9.5’s report gets one when the viewport row does.
Consequences (including negative)
-
The merge logic ships. Production can express a capability override even though nothing in its graph constructs one, because the constructor parameter is ordinary code. This is the residue of choosing a parameter over an installer and it is recorded rather than described as elimination. It is strictly less than an installer leaves, which is the whole basis of clause 2.
-
Q-033has no value, so the VRAM budget defaults tonulland enforces nothing. The registry records the budget as “currently absent entirely — no value here means the protection does not exist”, andnullis that sentence in code. The veto can set the budget arbitrarily small, so the ladder is testable before the ceiling is measured — but nothing here advancesQ-033, and a plausible default would have been a number nobody measured, read by the next author as one somebody had. -
Fourteen of sixteen Guardrail 7 rows are blank and release is blocked. That is not a regression: they were blank before, as prose. What changes is that the count is now computed and pinned rather than eyeballed at release review.
-
Two budgets §14.6 names are absent from the record — ADR-048’s single visibility-memory budget and §9.5’s plugin budgets — because neither has a consumer. A budget nothing reads is a number pretending to be a control. Guardrail 7 has no budget axis, so nothing in the matrix covers them either, and the test asserts that gap explicitly rather than inventing a row.
-
The ESLint ban will require scoped exemptions. §9.1 gives
SharedArrayBufferthree legitimate consumers, all Render Worker → Main Thread. That is the intended friction: the day one of them is written, the author adds a scoped exemption as a reviewed act rather than reaching for the global and discovering at runtime that nothing had checked it. -
Neither lint rule sees a computed access.
globalThis['SharedArrayBuffer']is not caught. The rules close the ordinary way a capability check gets written, which is what they are for; they are not a containment proof.
Enforcement
eslint.config.mjs—no-restricted-globalsandno-restricted-propertiesaterror, scoped tosrc/**withsrc/platform/capabilities.tsexempt. Verified by mutation: a file usingnavigator.gpu, a destructurednavigator.storage,SharedArrayBuffer,crossOriginIsolatedandindexedDBproduced five errors.forbidModulesInProductioninvite.config.ts— failsvite buildifsrc/platform/capability-veto.tsorsrc/testing/e2e-harness.tsappears in the production module graph, and throws if it could read no module ids at all. Verified by mutation: a static import of the veto failed the build naming the module and the chunk.tools/check-bundle-absence.mjs— run bypnpm run buildaftervite build; asserts the veto’s anchors and the publish routes are absent from the whole ofdist/, and treats an empty scan as a failure. Its own suite carries a positive control per anchor and asserts every anchor still exists in the veto source, so a rename cannot leave it grepping for a string the build can no longer emit.src/platform/guardrail7-matrix.test.ts— the two demonstrated rows, the ledger equality in both directions, and the coverage assertion that every capability and every fault appears in some row.- Not enforced, stated: the publish-route rule (ADR-123 clause 7) passes vacuously on the real bundle, because there is no publish code in this tree to detect — which is exactly what that clause decided. Its positive control is the only evidence the detector works, and the rule earns its place by being what goes red on the day somebody adds one.
§11 Index Line
ADR-124: The capability veto is compiled out by construction — the injection point is a frozen record resolved once at startup and the veto is a constructor argument rather than an installable hook; a mocking library and runtime feature flags are both rejected; enforcement is an ESLint ban on raw capability globals, an exact module-graph gate in the build, and a dist scan afterwards.