Everything @genclass/runtime wraps, replaces or listens to in a page, what each mode may change, what it never
does, how every action can be undone, and how everything is put back. The source is the code; each row names the file
and function. test/interception.test.ts checks this page against the code: it installs the runtime on a synthetic
browser global, compares what changed with the inventory at the end of this page, checks that destroy() restores it,
and counts the patch sites in src/ (a new addEventListener, defineProperty or global assignment fails the test
until this page is updated).
Version. Written for
@genclass/runtime0.3.0-beta.5 as it is on GitHubmain(released 2026-11-02). The copy in an npm package describes that package's version. As of 2026-11-02,npm i @genclass/runtime@betainstalls 0.3.0-beta.5 (the 0.3.0 line: 0.3.0-beta.1 to 0.3.0-beta.5, released 2026-10-22 to 2026-11-02), andnpm i @genclass/runtimeinstalls 0.2.3 (latest, 2026-10-28), which has no measured visual checks, noprotect(), no webhook sink and no interaction analytics. Items marked 0.3.0 are not in 0.2.x; the interaction analytics rows (src/analytics/) and the devtools overlay'stheme: "dark"default are 0.3.0-beta.1, thegenclass.analytics.<token>entry 0.3.0-beta.2.
The src/... links are relative to packages/runtime/ in the repository
(GenClass on GitHub): the npm package
ships the built dist/, not src/.
Related: THREAT-MODEL.md (what an attacker could do with this surface), SECURITY.md, TELEMETRY.md (what leaves the page), OPTIONS-SPEC.md (every option), API.md.
Contents
- At a glance
- Globals and prototypes it replaces
- Event listeners it adds
- Objects it instruments after handing them to the app
- Automatic state discovery (React, Redux, Zustand)
- Stores you register
- Other side effects
- The coding-agent link (development only)
- What each mode may change
- What it never does
- Actions: preconditions and reversibility
- Money and identity flows
- Turning it off and restoring the page
- Audit trail
- Machine-checked inventory
At a glance
| Mode | Wraps and listens | Holds (adds latency) | Changes what the app sees |
|---|---|---|---|
?genclass=off, enabled: false |
nothing | never | never |
observe (default) |
everything below | never | never |
guard (opt-in) |
everything below | a salient request, response, failure or (with policy.holdWrites) store write, up to the hold budget (150 to 800 ms) |
only through guard-tier actions: discard, defer, coalesce, delay |
heal (experimental) |
everything below | as guard | guard-tier plus block, serve_cached, retry, hedge, rollback, resync, custom actions |
any mode + connectAgent (@genclass/runtime/agent, development hosts, imported by the app's dev build) |
also the delivery gate and the request ignore list (below) | only with a schedule the developer or their agent set: a response waits for others, at most maxHoldMs (1.5 s) |
never what a response contains; only when it reaches the app |
Every action needs the model's calibrated probability over the permitted actions to reach the tier threshold, a
diagnosis other than expected, and must pass requests.protect, cross-origin, route scope, policy.allow/deny,
action limits, the breaker and onBeforeAction (src/decide/policy.ts gate,
src/runtime.ts RuntimeImpl.onDecision). The model never runs code: it picks a label from the list
of actions the runtime offered for that subject, and a label that was not offered is ignored.
Observers are installed by RuntimeImpl.installObservers (src/runtime.ts); observe: { fetch:
false, ... } leaves any of them out (timers defaults to on only when a document exists). Each installer is wrapped
in try/catch: an installer that throws (a read-only global) is skipped, never fatal.
Globals and prototypes it replaces
Each wrapper calls the original with the same this and arguments and returns its result. After destroy() a wrapper
is restored only if it is still the installed value; if another library wrapped it afterwards, GenClass's wrapper
stays in that chain as a pure pass-through (a disabled flag), so neither library breaks.
| Global | How | File, function | What it reads | What it may change (guard/heal only) | After destroy() |
|---|---|---|---|---|---|
window.fetch |
replaced by a wrapper | src/observe/fetch.ts installFetch |
method, URL, header names, a hash of header values (tracing ids excluded), the request body (strings, form data, ≤ 64 KB of a Blob, BufferSource or of one clone of a Request stream; a Request whose content-length is larger is not cloned at all) to compute an identity; a clone of each response (≤ 256 KB, ≤ 1 s; never text/event-stream) |
hold before sending; coalesce, delay, block, serve_cached (request); hold a failure, retry, serve_cached (failure); hedge, serve_cached (stall); hold a response (delivery defer, discard) |
restored |
XMLHttpRequest.prototype.open, send, abort, setRequestHeader |
replaced on the prototype | src/observe/xhr.ts installXHR |
method, URL, header names and values (for the identity hash), the body (strings and BufferSource for the identity; Blobs are not read), the completed response's JSON | hold before send; delay, block, serve_cached (request); hold the completion events (delivery). XHR failures are detection only (no retry) |
restored |
XMLHttpRequest.prototype.addEventListener, removeEventListener |
replaced on the prototype (completion listeners readystatechange, progress, load, loadend go through a thin wrapper so a held delivery can queue them) |
same | nothing | the order and time at which completion listeners run while a delivery is held | restored, unless a wrapped listener was added: then both stay as pass-throughs so removeEventListener still finds the wrapper |
window.WebSocket |
replaced by a subclass of the native constructor | src/observe/websocket.ts installWebSocket |
URL, message data (a text message is JSON-parsed and summarised only when something reads it: the delivery decision of a salient message, a situation, devtools; once, one parse for both), outgoing send summaries (likewise when read) |
hold an incoming message (and everything after it on the same socket, in order); never drops or changes one | restored; sockets created before stay subclass instances that pass everything through |
window.EventSource |
replaced by a subclass | src/observe/eventsource.ts installEventSource |
URL, event data (parsed and summarised when read, as for WebSocket) | hold an incoming event (order kept) | restored, as above |
window.setTimeout, window.setInterval |
replaced by wrappers that wrap the callback (a lazy op carries the causal context) | src/observe/timers.ts installTimers |
the delay (for the op's label) | nothing: the delay, this, arguments, return value and the timer id are the native ones |
restored |
history.pushState, history.replaceState |
replaced on the history object; the original runs first |
src/observe/nav.ts installNav |
the new path, query and hash (redacted before the model) | nothing | restored |
Storage.prototype.setItem, removeItem, clear |
replaced on the prototype; the original runs first | src/observe/storage.ts installStorage |
the key name only (redacted when it names a secret); never the value | nothing | restored |
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ |
installed when absent (a minimal DevTools hook); when React DevTools or React Refresh already installed one, its inject, onCommitFiberRoot and onCommitFiberUnmount are chained (theirs run first) |
src/discover/react.ts installReactDiscovery |
see state discovery | nothing (observed only) | chained functions restored; a hook GenClass installed stays (React keeps a reference to it) and is inert |
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__, window.__REDUX_DEVTOOLS_EXTENSION__ |
replaced by shims that call the real Redux DevTools extension first when it is installed | src/discover/redux.ts installReduxDiscovery |
see state discovery | Redux stores created through them become full adapters (their writes can be held, dropped, rolled back in guard/heal) | restored (deleted when they did not exist) |
window.GenClass |
the script-tag API object | src/cdn/global.ts install |
nothing | nothing | stays |
PerformanceObserver (an instance, not a patch) |
observes longtask entries |
src/observe/perf.ts installPerf |
long task durations | nothing | disconnected |
PerformanceObserver, MutationObserver (instances; measured visual checks only) |
layout-shift and resource entries (buffered); child list, text and the class, style, hidden, src, srcset, width, height, open attributes under documentElement |
src/visual/engine.ts Visual.install |
shift scores and their sources, resource URLs and responseStatus; which subtrees changed |
nothing | disconnected |
MutationObserver (an instance; interaction analytics only, InitOptions.analytics) |
child list, attributes and text under documentElement, for at most 1 s after a click on a link or button |
src/analytics/index.ts startAnalytics |
whether anything in the page changed (a dead click is one after which nothing did); changes inside [data-genclass-ignore] do not count |
nothing | disconnected after 1 s, at the first change, and on stop() or destroy() |
document.adoptedStyleSheets (measured visual checks, guard or heal with visual: { on: true, fix: true } only) |
one constructed stylesheet appended, one rule per fix ([data-genclass-fix~="f3"] { color: ... !important }) and that token in a data-genclass-fix attribute on the element |
src/visual/fix.ts Fixer.apply |
nothing | a readable text colour for text measured below 1.5:1 | rules and attributes removed |
Not wrapped or observed: requestAnimationFrame, queueMicrotask, MessageChannel, postMessage,
BroadcastChannel, IndexedDB, Cache Storage reads by the app, navigator.sendBeacon, Worker, Service Workers,
WebRTC, WebTransport, document.cookie, form navigation (a native <form> submit is recorded as a user action only).
Event listeners it adds
All of them are passive observers: none calls preventDefault, stopPropagation or changes the event. Exception: the
devtools overlay's Alt+Shift+G hotkey (only when you mount the overlay).
| Target | Events | File, function | Why | Removed by destroy() |
|---|---|---|---|---|
window (capture, passive) |
click, input, change, submit, keydown (Enter and Escape only), plus focusin, pointerdown, mousedown to find open shadow roots |
src/observe/dom-user.ts installDomUser |
user actions: the target's accessible name, the typed value (empty for password, card, one-time-code and secret-named fields: isSensitiveField), the option text |
yes (also the per-shadow-root change/submit listeners) |
window |
error, unhandledrejection |
src/observe/errors.ts installErrors |
errors become error triggers (message, file name and line) |
yes |
window |
popstate, hashchange |
src/observe/nav.ts installNav |
navigation (route scopes are recomputed) | yes |
window / document |
pagehide / visibilitychange |
src/runtime.ts constructor | the session summary for your sinks; in observe (0.3.0), deciding the situations deferred while the tab was hidden |
yes |
window / document |
pagehide / visibilitychange |
src/telemetry/client.ts TelemetryClient |
the final telemetry flush (only while telemetry is on) | yes |
window |
load (once) |
src/model/host.ts scheduleIdle |
start the model download at idle after the page loaded | yes |
document |
securitypolicyviolation (while the model loads) |
src/model/host.ts watchCsp |
name a CSP-blocked model download in one warning | yes, when loading ends |
document |
DOMContentLoaded (once) |
src/cdn/config.ts whenBody |
mount the devtools overlay when <body> exists |
fires once |
the model Worker |
message, error, messageerror |
src/model/host.ts, src/cdn/global.ts blobModuleWorker |
the worker GenClass started | terminated |
| inside the worker | message, securitypolicyviolation |
src/model/worker.ts | the worker's own scope | terminated |
| the devtools overlay (only when mounted) | keydown on document (capture: Alt+Shift+G toggles the overlay and calls preventDefault on that combination only), clicks and keys inside its own shadow root, matchMedia theme changes (only with theme: "auto", the default before 0.3.0-beta.1) |
src/devtools/index.ts, src/devtools/ui.ts | the overlay | on unmount |
document (capture), window |
securitypolicyviolation (at most 50 kept), load (once) |
src/visual/start.ts startVisual |
measured visual checks (VISUAL.md), only when InitOptions.visual is on for this page: violations during page load cannot be queried later; the checks' chunk loads 1.5 s after load |
securitypolicyviolation yes; load fires once and is inert after destroy() |
document, window, document.fonts (capture, passive; only once the visual chunk runs) |
document: error, load, securitypolicyviolation, click, pointerover, focusin, pointerdown, pointerup, pointercancel, keydown, visibilitychange, prerenderingchange; window: wheel, touchmove, scroll, hashchange, popstate, resize, pageshow; document.fonts: loadingdone |
src/visual/engine.ts Visual.install |
measured visual checks: failed assets, clicks swallowed by an invisible layer, hover and focus contrast, focus under a sticky header, scroll attempts on a locked page. Read only: never preventDefault, stopPropagation or a write |
yes |
window (capture, passive), document (only with InitOptions.analytics or rt.analytics.start(), once the analytics chunk runs) |
window: click, pointerdown, pointermove, keydown, wheel, touchstart (capture), scroll, pagehide, pageshow; document: visibilitychange, prerenderingchange (once, on a prerendered page) |
src/analytics/index.ts startAnalytics |
interaction analytics (ANALYTICS.md): page views, engaged time, scroll depth, and clicks, rage clicks and dead clicks per element key, sent as interactions telemetry events. Trusted events only; never preventDefault, stopPropagation or a write; nothing reaches the event log or the model |
yes, and on rt.analytics.stop() |
Objects it instruments after handing them to the app
| Object | What | File | Mode | Effect |
|---|---|---|---|---|
each Response the app receives from fetch |
own properties json, text, arrayBuffer, blob, formData, bytes and clone that call the native methods and then restore the request's causal context |
src/observe/fetch.ts instrumentResponse |
all | same results, one extra microtask; res.json !== Response.prototype.json |
a Response GenClass made (guard/heal actions only) |
a new Response with the header x-genclass: cached, coalesced or blocked (503, empty body) |
src/observe/cache.ts makeResponse, blockedResponse |
guard: coalesce; heal: block, serve_cached |
the app can tell it apart by that header |
each XMLHttpRequest the app uses |
own accessor properties onreadystatechange, onprogress, onload, onloadend (the getter returns the wrapper around the app's function) |
src/observe/xhr.ts wrapHandlers |
all | same calls, possibly queued while a delivery is held |
an XMLHttpRequest GenClass answered (heal: block, serve_cached) |
own properties readyState, status, statusText, response, responseText, getResponseHeader, getAllResponseHeaders, then readystatechange, load, loadend are dispatched; all removed on the next open() |
src/observe/xhr.ts fake, unfake |
heal | the response carries x-genclass |
each WebSocket created while installed |
an own send that records the send (its summary computed when read), then calls the native send with the same data; GenClass's own listeners for message, close, error, open |
src/observe/websocket.ts, src/observe/messages.ts MessageGate |
all; holding only in guard/heal | a held message is stopped (stopImmediatePropagation) and later re-dispatched as a copy (isTrusted false, same data, origin, lastEventId, ports), in order |
each EventSource |
addEventListener (subclass method) makes sure GenClass sees custom event types first |
src/observe/eventsource.ts | all; holding only in guard/heal | as WebSocket |
Automatic state discovery (React, Redux, Zustand)
Installed with autoState (on by default for @genclass/runtime/auto and the script tag; GenClass.init needs
import "@genclass/runtime/discover" first), never under ?genclass=off. Code: src/discover/.
| Source | Hook point | What GenClass does there | Store kind | May GenClass change it? |
|---|---|---|---|---|
| React ≥ 16.8 (all renderers, dev and prod builds) | __REACT_DEVTOOLS_GLOBAL_HOOK__ (above) |
on each commit, walks only the fibers that re-rendered (≤ 1 ms, ≤ 20,000 fibers per commit) and records useState, useReducer, useSyncExternalStore and class-component state of named components (≤ 3 instances per component, ≤ 48 component stores, ≤ 16 hooks each). Functions, promises, elements, DOM nodes and class instances are skipped; a value equal to what a password, card or one-time-code input holds is redacted from then on |
observed |
No. Never held, dropped, reverted, rolled back or resynced (state/hub.ts StoreHub.observe only records; mutation discard/defer are not offered: situation/build.ts builtinUnavailable; rollback needs a writable store) |
| React renderers that inject after GenClass | the renderer's hooks dispatcher (ReactSharedInternals.H in React 19, ReactCurrentDispatcher.current before) becomes an accessor that returns an object inheriting from React's dispatcher with own useState/useReducer whose setters are wrapped; class instances' updater is wrapped |
the wrapper calls the real setter unchanged and first notes which operation (request, timer, user action) called it | none | No. After destroy() the wrappers stay (React keeps the setters' identity) and pass through |
| Redux and Redux Toolkit stores created with the Redux DevTools compose or enhancer (RTK's default) | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__, __REDUX_DEVTOOLS_EXTENSION__() |
adds GenClass's Redux enhancer innermost (as the manual genclassEnhancer, src/adapters/redux.ts); the real extension stays outermost |
adapter (full) |
Yes, in guard/heal: like a registered store (a salient async dispatch may be held with policy.holdWrites, dropped, late-reverted within 800 ms, rolled back). Redux stores created before a runtime attached are observed only |
Zustand devtools() middleware and other Redux DevTools connect() clients |
__REDUX_DEVTOOLS_EXTENSION__.connect |
records every state they send (functions left out), attributed to the operation that made the change | observed |
No |
test/invariants/discovery.test.ts checks the "No" rows with a hostile model in heal mode.
Stores you register
rt.atom, rt.guard, rt.adapter, useGenClassState (src/adapters/react.ts), the Redux
enhancer (src/adapters/redux.ts: wraps the store's dispatch and previews each action
through the reducer once) and the Zustand middleware (src/adapters/zustand.ts: replaces the
store's set and api.setState). These are the only stores GenClass can write: guard may drop or defer a held write
(policy.holdWrites, opt-in) or late-revert one within 800 ms; heal may roll back or resync. GenClass writes whole
states back to Redux with the visible action @@genclass/REPLACE. Store options: hold: false keeps a store's writes
from ever being held.
Other side effects
| What | Where | When | File |
|---|---|---|---|
Network: the model card, tokenizer, calibration, meta and the model variant (about 10 MB) from model.baseUrl (default jsDelivr @genclass/runtime-model), and onnxruntime-web's wasm from model.ortWasmPaths |
with the page's fetch as it was when the module loaded (never through the wrapper) | at idle after load, GenClass.init in a browser with a model; not on devices reporting 2 GB or less nor on Save-Data (model.loadIf default, 0.3.0; 0.2.x loads on every device, and on Save-Data only when first needed) |
src/model/loader.ts, src/model/backend.ts |
Network: anonymous diagnostics to the GenClass collector (DEFAULT_TELEMETRY_ENDPOINT, a Cloudflare Worker): a summary about once a minute while decisions continue; every detection with its decision record, and every other non-routine decision record; routine decision records (top diagnosis expected, nothing acted) sampled 1 in 4 and capped at 200 per page load (0.3.0; 0.2.x sends every decision record) |
native fetch/sendBeacon captured at module load, text/plain, credentials: "omit", referrerPolicy: "no-referrer" |
GenClass.init in a browser unless turned off (TELEMETRY.md); never in createRuntime() |
src/telemetry/ |
Network (0.3.0): a webhook sink's batched POSTs of { schema: "genclass-sink/1", version, records } to the URL the app configured, only when the app adds webhook() from @genclass/runtime/sinks/webhook or names webhook:<url> in its config (init --sink webhook:<url>) |
with /auto and a named sink, first a dynamic import() of the sink resolver and then of the adapter, both lazy chunks of the app's own bundle; with the script tag, a dynamic import() of the adapter, dist/sinks/webhook.js, from where the script was loaded (the CDN, or data-base when self-hosted); a named console-json sink loads its adapter the same way and sends nothing. The POSTs use navigator.sendBeacon, else fetch with keepalive, both looked up at send time; headers force the fetch path; when the fetch path is used, list the URL in requests.ignore (the zero-code entries do this for webhook:<url>) |
with the zero-code entries, the imports when the first sink record arrives; then each round of sink records, and on pagehide or when the tab is hidden |
src/sinks/webhook.ts, src/cdn/sinks.ts namedSinks, src/cdn/sinks-load.ts |
| Cache Storage | genclass-model cache (model.cacheName): model files, each checked against the card's size and sha256 |
model load | src/model/loader.ts fetchFile |
sessionStorage |
genclass:bucket (sample: act@<sample> or observe@<sample>, reused only under the same sample; never read or written at sample 0 or 1), the breaker's trip state |
sample < 1; breaker trips |
src/runtime.ts sampleBucket, src/decide/breaker.ts |
localStorage / sessionStorage |
genclass:learn (only with learn.expectations: true (0.3.0) or learn.persist: learned flows, and operation shapes too with learn.persist: signatures, routes and field names with id-like parts normalised, counts, hashed action labels; ≤ learn.maxBytes), genclass:learn:probe (set and removed once at init when a store is wanted, to check storage works), genclass:learn:session in sessionStorage (the tab's session ordinal, with a localStorage store) |
only with learn.expectations: true or learn.persist (by default nothing is stored); with learn.expectations alone, not in jsdom / happy-dom; learn.persist: false turns it all off; the runtime's own writes are not recorded as storage events |
src/runtime.ts saveProfilesSoon, startSession, docs/runtime/EXPECTATIONS.md |
localStorage |
genclass.analytics.<token>: the visit id (16 random hex characters), the time of the visit's last page view or event, and a visited-before flag; written with the storage observer muted (rt.selfStorage), so it never becomes a storage event |
interaction analytics on (InitOptions.analytics, rt.analytics.start()): each page view and each interactions event; removed by rt.analytics.stop() |
src/analytics/index.ts startAnalytics |
localStorage (read only) |
genclass (kill switch; read only when the URL has no ?genclass= value, which takes precedence), genclass.telemetry (opt-out) |
init | src/index.ts killSwitch, src/telemetry/config.ts |
A module Worker (genclass-model) running onnxruntime-web; inline on the main thread only if the worker cannot start (model.inlineFallback, default true; false (0.3.0; 0.2.x ignores it): no model then, status skipped / worker-unavailable) |
model load | src/model/host.ts | |
| Memory | ≤ 64 response bodies of ≤ 256 KB (last good GET per identity), ≤ 4 MB of recent responses for coalescing, a 500-event history, 200 decisions, 1,000 audit entries | always | src/observe/cache.ts |
| Console | one line per detection or intervention (report), the telemetry notice, warnings |
report: "console" (default) |
src/decide/report.ts |
Network: the measured visual checks' chunk (dist/visual/index.js, from the same place as the runtime) |
a dynamic import() |
1.5 s after load when InitOptions.visual is on for the page (development hosts by default), or when the devtools overlay mounts or rt.checkNow() runs |
src/visual/start.ts |
Network: the interaction analytics chunk (dist/analytics/index.js, from the same place as the runtime), then interactions events on the telemetry batches |
a dynamic import() |
only when the app turned analytics on (InitOptions.analytics, rt.analytics.start()) and telemetry is on with the app's token or a custom endpoint |
src/analytics/start.ts |
| Console | [GenClass] Check (measured): ..., one collapsed group per finding with its measured values (at most 10 a pass, then one line) |
measured visual checks on | src/visual/bridge.ts |
| Console | [GenClass] Interaction analytics are on ... once per page; [GenClass] analytics: true needs the app's token; nothing is recorded. |
analytics on; analytics: true without a token |
src/analytics/index.ts, src/analytics/start.ts |
The coding-agent link (development only)
connectAgent(rt) from @genclass/runtime/agent (docs/runtime/AGENT.md) is not part of
GenClass.init(), /auto or the script tag: an app imports it in its development build. It returns an inert
connection on any page not served from this machine (localhost, 127.x, *.localhost; anyHost: true
widens it) and with a non-loopback bridge URL. Connected, it:
| What | Where | When | File |
|---|---|---|---|
Wraps the runtime's delivery gate (RuntimeImpl.runDelivery) |
the instance property; disconnect() (and rt.destroy()) restores it and delivers anything held |
always while connected; holds only fetch/XHR responses and only with a schedule (fifo, seeded, given): each at most its delay plus maxHoldMs; without a schedule a page load's first responses wait for the bridge's hello (at most helloTimeoutMs, 600 ms; hold: false turns every hold off); failures, aborts, WebSocket/EventSource messages, protected requests and routes in mode "off" are never held |
src/agent/index.ts, src/agent/schedule.ts |
Wraps the request ignore list (RuntimeImpl.mIgnore) |
so the fetch observer passes the link's own requests to the bridge straight through (no op, no event) | while connected | src/agent/index.ts |
Wraps window.setTimeout and window.clearTimeout (over the runtime's own wrap) |
counts pending app timers of at most 1 s (debounces) so a page is not "quiet" before its debounced request starts; every call goes to the original with the same arguments; intervals and longer timers pass untouched | while connected; disconnect() restores both if nothing wrapped them since |
src/agent/index.ts |
Listens to error, unhandledrejection (for stacks) and pagehide (final flush) on the page's global |
non-capturing, passive reads | while connected | src/agent/index.ts |
Reads the page's visible text (document.body.innerText) and form values (not password, card, one-time-code or secret-named fields) |
at quiet points (no event for 400 ms) | while connected, dom: true (default) |
src/agent/index.ts snapshot |
Network: hello and batched events to the local bridge (http://127.0.0.1:47357) |
the page's fetch (passed through by the ignore list) and sendBeacon on exit; text/plain, credentials: "omit", referrerPolicy: "no-referrer"; loopback URLs only |
while connected | src/agent/link.ts |
| Global | __GENCLASS_AGENT__ (the connection, for tests); reads __GENCLASS_SCHEDULE__ and __GENCLASS_AGENT_URL__ |
while connected | src/agent/index.ts |
It never writes to the event log and never feeds a situation, a decision or the model.
What each mode may change
observe (the default) never holds, delays, drops, retries, replays or answers anything, and never writes app state.
In code: permittedActions is empty in observe (src/decide/policy.ts modeAllows), so
trigger() never waits; deliveryHoldable is false, so a response is released synchronously before any body read
(runDelivery); requestHoldable is false, so a request whose body GenClass reads for its identity is sent first
(src/observe/fetch.ts runRequest); store holds need mode !== "observe" (hub.hooks.mayHold).
What observe does cost: the wrappers above, a few extra microtask hops, response clones, CPU for situations and the
model (in its worker), the model download and, from GenClass.init, telemetry. test/invariants/observe.test.ts
compares everything the app observes, with virtual timestamps, against a run without GenClass while a hostile model
answers every situation.
guard (opt-in) adds holds and the guard-tier actions; heal (experimental) adds the heal tier. A hold never
exceeds the hold budget (policy.holdBudgetMs, "auto" = 1.5 × the model's median latency clamped to 150 to 800 ms),
including the time spent waiting for a body, deferred re-decisions and onBeforeAction
(test/invariants/network.test.ts, test/invariants/state.test.ts "the hold budget caps ..."). When the model is
not ready, slow, erroring or over its rate limit, the subject proceeds unchanged (fail open).
Measured visual checks (VISUAL.md) only read in every mode. Their runtime fixes need
visual: { on: true, fix: true } and guard or heal mode, and only ever do two things: add an owned stylesheet rule
that sets a readable colour on one element (text measured below 1.5:1), or remove one leaked inline
pointer-events: none or overflow: hidden declaration on html or body after recording it. Each is re-measured
50 ms later and undone if it did not help, undone by finding.fix.undo(), and undone automatically on
setMode("observe"), the breaker, pause() (within 2 s), disable() and destroy()
(src/visual/fix.ts).
Every term can only lower the mode: routes rules, sample, the breaker (after 2 undos or 3 errors following actions,
the session drops to observe), a hidden tab (nothing new is held; background decisions are skipped in guard and heal, and deferred until the tab is
visible in observe, where they can only detect) and
?genclass-mode (and ?genclass-aggr, ?genclass-sample=0), on every host. Since 0.2.2 no URL or localStorage
switch can raise the mode on a production host, and since 0.2.3 and 0.3.0-beta.2 debug: true does not change that:
?genclass=guard or heal raises only on a development host, http(s) on a loopback name with an explicit port or
*.test (src/devhost.ts; never Capacitor, Tauri, Electron, intranet or private-address hosts; see
THREAT-MODEL.md).
What it never does
| Never | How it is ensured | Test |
|---|---|---|
| changes a request's URL, method, headers or body | the native fetch/send gets the app's own input/init/body; a replay (retry, hedge) sends the same init, or a Request rebuilt from the original with the ≤ 64 KB of body bytes read for its identity (larger Request bodies are not replayable) |
test/fetch.test.ts, test/invariants/network.test.ts |
reads or writes cookies, or changes credentials |
document.cookie is never accessed; init is passed through |
static: no cookie access in src/ outside redaction name lists |
| sends a non-idempotent request twice without an idempotency key | retry and hedge are only offered for GET/HEAD/OPTIONS/PUT/DELETE/TRACE or with an Idempotency-Key-style header (policy.idempotencyHeaders) or opt-in body field (policy.idempotencyBodyFields): situation/build.ts repeatUnsafe; hedge is GET only |
test/invariants/network.test.ts "never sent twice" |
| serves a cached response to anything but an identical GET | serve_cached needs method === "GET" and a cached body for the same identity (method, URL, headers, body) |
test/review-fetch.test.ts, test/fetch.test.ts |
| acts on a protected, cross-origin or off/observe-scoped subject | gate steps 1 to 3 (blockOf → reason protected, cross-origin, scope), checked before thresholds, allow, eager and shadow; onBeforeAction is not even asked; a protected request is never held and never waits for its body; its response's causal chain is protected too |
test/invariants/network.test.ts, test/presets.test.ts |
| runs an action the runtime did not offer | top must be one of built.actions; the gate only considers permitted, offered actions |
test/invariants/network.test.ts "out-of-vocabulary" |
| writes discovered React or Zustand state | observed stores are only recorded (StoreHub.observe) |
test/invariants/discovery.test.ts |
| reorders writes to a store, or messages on a socket | held writes wait in the store's queue (StoreHub.drain applies in proposal order); held messages queue behind the head (MessageGate) |
test/no-reorder.test.ts, test/invariants/network.test.ts "sync loop" |
| records password, card or one-time-code input values | isSensitiveField → empty value; the default redactor runs first and a custom redact can only redact more |
test/redaction-v2.test.ts, test/review-redaction.test.ts |
| throws into the app | installers, hooks, listeners, sinks and plugins run in try/catch; a failed action runs the passive action |
throughout |
keeps acting after disable() |
holds are released unchanged, observers uninstalled, the model worker terminated | test/invariants/state.test.ts "disable" |
Actions: preconditions and reversibility
Holding (waiting for the decision) is not an action: it only adds latency, within the hold budget, and the subject then
proceeds or the chosen action runs. Every action is recorded (rt.interventions(), rt.explain(id), the console,
sinks, rt.audit()) with one sentence saying exactly what changed. Undo: record.undo(), the devtools overlay, or
rt.disable({ undo: true }) (actions of the last 60 s, newest first). Two undos within 10 minutes trip the breaker.
| Trigger | Action | Tier | What it does | Preconditions (besides the gate) | Undo |
|---|---|---|---|---|---|
| mutation | discard |
guard | drops a held store write; decided after the write applied (hold budget expired), reverts exactly that write | registered store (not observed-only); held: policy.holdWrites; late revert ≤ 800 ms after it applied and nothing written since depends on it (StoreHub.revertable) |
yes: the write is committed / re-applied |
| mutation | defer |
guard | holds the write until related in-flight operations finish, then decides again | held write, at most 2 defers, within what is left of the hold budget | not needed: the write applies or is decided again |
| request | coalesce |
guard | does not send; answers with the response of the identical request in flight or finished ≤ 2 s ago (x-genclass: coalesced) |
fetch; same identity (method, URL, headers, body); a shareable buffered response (≤ 256 KB) | no: the request was not sent. Any method, POST included (double submits): protect or deny it where repeats are deliberate |
| request | delay |
guard | sends after 250 ms × 2^(recent failure streak), at most 8 s | fetch, XHR | no (latency only) |
| request | block |
heal | does not send; answers 503 with x-genclass: blocked |
fetch, XHR | no |
| request | serve_cached |
heal | does not send; answers with the last good response for this identity (x-genclass: cached) |
GET; a cached body (≤ 256 KB, this page's memory) | no |
| delivery | discard |
guard | delivers the response, then for 10 s drops the writes its causal chain makes over newer data | the response would overwrite newer data; at least one target field in a registered store | yes: the dropped values are restored |
| delivery | defer |
guard | holds the response until related in-flight operations finish, then decides again | related work in flight; at most 2 defers; within the hold budget | not needed: the response is delivered or decided again |
| failure | retry |
heal | sends the request again after 200 ms × 2^(attempt − 1) (≤ 5 s); the app gets that attempt's result | fetch; replayable body; fewer than 4 attempts; idempotent method or idempotency key | no |
| failure | serve_cached |
heal | answers the failed request with the last good response | fetch; GET; cached body | no |
| stall | hedge |
heal | sends a second identical request; the first good answer reaches the app | fetch; GET; replayable | no |
| stall | serve_cached |
heal | answers with the cached response; the original continues in the background | fetch; GET; cached body | no |
| inconsistency | rollback |
heal | restores the involved writable stores to the last consistent snapshot | a snapshot exists; an involved store is writable | yes: the replaced values are restored and the violation is muted |
| inconsistency, transition | resync |
heal | calls the store's resync handler (your code) |
a resync handler |
your handler |
| transition, error | rollback |
heal | restores only the fields the operation's own chain wrote (and nobody overwrote since) | such fields exist in writable stores | yes |
| any | custom (rt.action, plugins) |
heal unless declared guard |
your code; ctx.builtin(name) runs a built-in under the same gate |
on triggers; applicable() |
ctx.onUndo(fn) |
Money and identity flows
Automatic intervention in payment, checkout and sign-in flows is not recommended. Keep them observe-only with
requests.protect, which is checked before everything else and covers the request, its response and everything its
response callbacks cause:
import { GenClass, protectPreset } from "@genclass/runtime";
GenClass.init({ mode: "guard", requests: { protect: [...protectPreset("payments", "auth"), "/api/cart"] } });
// JSON configs (window.GENCLASS_CONFIG, the options file `init` writes, a meta tag):
window.GENCLASS_CONFIG = { requests: { protect: ["preset:payments", "preset:auth"] } };
npx @genclass/runtime@beta init suggests the second form when the project depends on a payment SDK (Stripe, PayPal,
Braintree, Adyen, Square, Paddle, ...) or has source files named like a checkout or payments page. The presets
(src/presets.ts) match URL words such as checkout, payment(s), payment_intents, billing,
invoice, charge, refund, subscription, order, transaction, transfer, payout, wallet and provider
names (Stripe, PayPal, Braintree, Adyen, Klarna, Square); auth matches login, logout, sign-in, sign-up,
oauth, token, session, sso, saml, oidc, mfa, otp, password, verify. They over-match on purpose
(protection only removes interventions). Cross-origin requests (a payment provider's own API) are never acted on in
any case. Alternatively put the routes under a routes rule with mode: "observe".
Turning it off and restoring the page
| How | Effect | Code |
|---|---|---|
?genclass=off or localStorage.genclass = "off" |
nothing is installed: no observer, no discovery hooks, no model, no telemetry | src/index.ts initUnsafe, src/cdn/config.ts isKilled |
enabled: false |
installs nothing | RuntimeImpl constructor |
enabled predicate / source turning false |
pass-through: held subjects released, nothing decided | followEnabled |
rt.pause() |
nothing is held or acted on until rt.resume() |
|
rt.disable() |
releases every held subject unchanged, then destroy() |
disable |
rt.disable({ undo: true }) |
also undoes the actions of the last 60 s, newest first (not counted by the breaker) | disable |
rt.destroy() / GenClass.destroy() |
telemetry's final flush, every uninstaller in reverse order (the "After destroy()" column above), plugins' cleanups, store subscriptions, the model worker |
destroy |
Per-object instrumentation already handed to the app (a Response's methods, an XHR's handler accessors, sockets
created while installed) stays attached and passes through.
Audit trail
rt.audit(n?) returns JSON-serialisable entries for every decision, action, undo, breaker trip and reset, and control
change (setMode, setAggressiveness, pause, resume, enabled, disable), each with a timestamp, sequence
number, session id, the effective and requested mode, the aggressiveness profile, the gate kind, thresholds and their
source, the model's probabilities, the action proposed and the one that ran with the reason, the hold and the model
(name, version, variant, device and the sha256 its file was verified against). InitOptions.audit sets the in-memory
size (default 1,000) and a sink for your own logging. Query values in free text are replaced with "…". The trail is
built from values the runtime already has: it never feeds a decision, never changes what the model reads and is never
sent anywhere by GenClass (src/decide/audit.ts; API: API.md).
Machine-checked inventory
test/interception.test.ts parses this block. patch lines name what a runtime with every observer and autoState
changes on a browser global, and whether destroy() restores it or leaves it in place (inert). listen lines are the
listeners it adds to window and document (all removed by destroy()). sites lines count the patch sites per file
in src/ (addEventListener( calls, Object.defineProperty(, Reflect.set/defineProperty, assignments to
global, prototype and hook objects): a change in a count fails the test until the rows above and this block are
updated. src/protect.ts counts two Object.defineProperty( calls that are not interceptions: protect() sets
name and length on the wrapper function it returns (its own object, never a page global; nothing to restore).
patch window.fetch restored
patch window.setTimeout restored
patch window.setInterval restored
patch window.WebSocket restored
patch window.EventSource restored
patch window.__REACT_DEVTOOLS_GLOBAL_HOOK__ stays
patch window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ restored
patch window.__REDUX_DEVTOOLS_EXTENSION__ restored
patch XMLHttpRequest.prototype.open restored
patch XMLHttpRequest.prototype.send restored
patch XMLHttpRequest.prototype.abort restored
patch XMLHttpRequest.prototype.setRequestHeader restored
patch XMLHttpRequest.prototype.addEventListener restored
patch XMLHttpRequest.prototype.removeEventListener restored
patch history.pushState restored
patch history.replaceState restored
patch Storage.prototype.setItem restored
patch Storage.prototype.removeItem restored
patch Storage.prototype.clear restored
observe PerformanceObserver longtask
listen window click
listen window input
listen window change
listen window submit
listen window keydown
listen window focusin
listen window pointerdown
listen window mousedown
listen window error
listen window unhandledrejection
listen window popstate
listen window hashchange
listen window pagehide
listen document visibilitychange
sites src/adapters/zustand.ts 1
sites src/agent/index.ts 4
sites src/analytics/index.ts 2
sites src/cdn/config.ts 1
sites src/cdn/global.ts 3
sites src/devtools/index.ts 7
sites src/devtools/ui.ts 1
sites src/discover/react.ts 7
sites src/discover/redux.ts 2
sites src/model/backend.ts 2
sites src/model/host.ts 5
sites src/model/worker.ts 2
sites src/observe/cache.ts 1
sites src/observe/dom-user.ts 3
sites src/observe/errors.ts 2
sites src/observe/eventsource.ts 4
sites src/observe/fetch.ts 4
sites src/observe/nav.ts 6
sites src/observe/storage.ts 6
sites src/observe/timers.ts 4
sites src/observe/websocket.ts 4
sites src/observe/xhr.ts 15
sites src/protect.ts 2
sites src/runtime.ts 1
sites src/telemetry/client.ts 2
sites src/visual/engine.ts 3
sites src/visual/start.ts 1
Rendered from packages/runtime/INTERCEPTION.md in the GenClass repository.