Skip to content

Latest commit

 

History

History
1074 lines (946 loc) · 86.5 KB

API.md

File metadata and controls

1074 lines (946 loc) · 86.5 KB

@genclass/runtime API reference

GenClass Runtime watches a web app from the inside (user actions, async operations, network, state changes, errors, timing and causality) and asks a small local model, running in the browser, about situations that look risky. Depending on the mode it reports what it saw, or prevents the failure with a minimal, reversible action.

import { GenClass } from "@genclass/runtime";

const rt = GenClass.init();                          // observe mode, local model, console reports
const cart = rt.atom("cart", { items: [], total: 0 }); // state GenClass can protect

Contents: init and modes · options · state · operations and user actions · questions · events · introspection · triggers and actions · policy · reports · measured visual checks · plugins · headless use · adapters and devtools · types

Init and modes

GenClass.init(options?: InitOptions): Runtime   // idempotent: a second call returns the same runtime
GenClass.runtime: Runtime | null
GenClass.destroy(): void                        // uninstall observers, restore globals, terminate the model worker
GenClass.protect: typeof protect                // the same as the named export below (0.3.0: not in 0.2.x)

// named exports (0.3.0: not in 0.2.x)
protect<F extends (...args: never[]) => unknown>(name: string, fn: F): F   // see "Protected functions"
TOKEN_PATTERN: RegExp                           // /^gc_[A-Za-z0-9]{22}$/
isValidToken(v: unknown): v is string
mode what it does
observe (default) Watches and reports detections. Never changes execution: nothing is held, no action runs.
guard (opt-in) Also takes guard-tier actions at very high confidence: discard, defer, coalesce, delay. These withhold, deduplicate or slow something down; they never invent data or fail a request.
heal (experimental) Also takes heal-tier actions: block, serve_cached, retry, hedge, rollback, resync, plugin actions.

Kill switch, for "is my app broken or did GenClass change something?":

  • ?genclass=off in the page URL, or localStorage.genclass = "off": init installs nothing and logs one line.
  • ?genclass=observe|guard|heal: overrides the mode, within limits (src/index.ts -> initUnsafe; a missing mode counts as guard there): observe always; guard when no mode, guard or heal is configured (the default /auto and script tag included, not /auto/observe); heal only when heal is configured, or with debug: true.

Outside a browser (no window/document, e.g. SSR), GenClass.init() returns an inert runtime: no observers, no model. Use createRuntime for headless work.

Telemetry (privacy-relevant). In a browser on a public host, GenClass.init() sends anonymous diagnostics (decisions, action outcomes, detections, model status and counts; no page text unless telemetry: { include: { situation: true } }) to DEFAULT_TELEMETRY_ENDPOINT and prints one console notice per page. Off with telemetry: false, ?genclass=no-telemetry (or ?genclass=off), localStorage["genclass.telemetry"] = "off", or Global Privacy Control; off by default on localhost and private hosts (0.2.1 sends from them too, by mistake; fixed in 0.2.2), outside a browser and in createRuntime(). An explicit telemetry: true also sends from local hosts. Schema and details: packages/runtime/TELEMETRY.md.

App token and dashboard (0.3.0: 0.2.x ignores token). token: "gc_..." (from npx @genclass/runtime@beta init or https://genclass.dev/start) is sent with each telemetry batch so the app's diagnostics show up on its private dashboard (https://genclass.dev/dashboard/<secret>; free on every plan, and npx @genclass/runtime@beta dashboard prints the link for the app in the current folder); with a token, decision events also carry fn (see "Protected functions"). Public; malformed values log one warning and are ignored; it does nothing while telemetry is off (on a local host the console says so once). rt.telemetry.token reports it while telemetry is on. token and scope are fixed when GenClass starts: on a runtime already started by /auto or the script tag, GenClass.init({ token, scope }) is ignored with one console warning (set them in window.GENCLASS_CONFIG, the meta tag or data-token / data-scope).

Interaction analytics (0.3.0-beta.1; ANALYTICS.md). Off by default. analytics: true with a token (or a custom telemetry.endpoint) also sends interactions telemetry events for the app's dashboard: per page view the route, engaged time, scroll milestones, and clicks, rage clicks and dead clicks per element key (built from markup: data-genclass-name, data-testid, a stable id, a link's target, stable classes, a short structural path; never element text), and (Unreleased) per visit a random visit id kept 30 minutes in localStorage (genclass.analytics.<token>), the referring host, utm values without ids, device class, browser and OS families and whether the browser visited before, and how visitors move within a page load: how each view was reached (nav), the previous route and the time between (from, fromMs), the first element interacted with (first, firstMs), and at the end of the page load where it ended and its routes in order (exit, path, at most 10; ANALYTICS.md section 11). Every telemetry opt-out and Global Privacy Control turn them off; without a token analytics: true warns once and records nothing. They run in a lazy chunk (dist/analytics/index.js), outside the event log: nothing reaches a situation, a decision or the model. rt.analytics.start() / stop() turn them on and off later (consent; stop() drops the counts not sent yet and removes the visit entry). Also data-analytics="on", analytics=on in the meta tag, window.GENCLASS_CONFIG.analytics. Schema: TELEMETRY.md. With the script tag, a chunk that does not load warns once per load ([GenClass] analytics: <url> (@genclass/runtime <version>) did not load; nothing is recorded. ..., reason: "error"), and a chunk of another release than the tag warns once with both versions. 0.2.x (npm's latest until 0.3.0) and 0.3.0-beta.0 have no analytics and ignore the option silently: install @genclass/runtime@beta (ANALYTICS.md section 4.1).

Options

interface InitOptions {
  mode?: "observe" | "guard" | "heal";                 // default "observe"; "guard" is opt-in, "heal" experimental
  model?: {                                             // or false: no model (observe only)
    baseUrl?: string;                                   // directory with model.json (default: jsDelivr CDN)
    device?: "auto" | "webgpu" | "wasm";                // default "auto"
    worker?: boolean;                                   // default true
    preload?: "eager" | "idle" | "lazy";                // default "idle"
  } | false;
  decider?: DecisionProvider | null;                   // bring your own decision provider instead of the model
  report?: "console" | "silent" | ((r: Report) => void); // default "console"
  observe?: Partial<Record<"fetch"|"xhr"|"user"|"errors"|"nav"|"storage"|"perf"|"websocket"|"eventsource"|"timers"|"untrustedEvents", boolean>>;
                                                        // untrustedEvents (default false): record synthetic DOM events (isTrusted false) as user actions (test harnesses)
  triage?: "salient" | "always";                        // default "salient"
  detect?: "likely" | "possible";                       // (0.3.0) which detections are reported; default "possible" in observe
                                                        // without policy.thresholds.report, else "likely" (see "Reports, explain and undo");
                                                        // set explicitly, "possible" also sends them to rt.on("detect"/"report") and report: fn
  policy?: PolicyOptions;                               // see Policy
  redact?: (path: string, value: unknown) => unknown;   // default: values whose leaf field names a secret (password, token, card number, cvv, ssn, iban, ...); never a whole store by its name
  plugins?: Plugin[];
  historySize?: number;                                 // events kept, default 500
  debug?: boolean;                                      // console.debug every decision
  learn?: LearnOptions;                                 // learned state and flow expectations; see "Learned flows and expectations"
  aggressiveness?: "cautious" | "balanced" | "eager" | number; // how eagerly to act (default "balanced"; see Policy)
  vocabulary?: { diagnoses?: Record<string, string>; actions?: Record<string, string> };
  settleMs?: number;                                    // quiet time that makes a settled point, default 60
  situation?: { budget?: number | "auto" };             // size of what the model reads, in characters (default "auto")
  telemetry?: boolean | {                               // anonymous diagnostics (TELEMETRY.md); default: on in GenClass.init in a browser, else off
    endpoint?: string;                                  // [DEFAULT_TELEMETRY_ENDPOINT]
    sample?: number;                                    // [1] fraction of page loads that send
    flushMs?: number;                                   // [10000] batch interval; also sent on pagehide / hidden tab (sendBeacon)
    maxBatch?: number;                                  // [100] events per request (requests also ≤ 60 KB)
    include?: { situation?: boolean };                  // [false]; true: include the redacted situation text
    decisionSample?: number;                            // [0.25] share of routine decisions (top diagnosis "expected", nothing acted) whose record is sent; every other decision always; counts exact
    maxDecisions?: number;                              // [200] most routine decisions' records per page load (Infinity: no cap); detections are never capped; summaries continue
    transport?: { send(url: string, body: string, o: { beacon: boolean }): void | Promise<unknown> }; // tests / custom pipelines; a rejection with `status` 429 or `retryAfterMs` is a back-off (0.3.0)
  };
  visual?: boolean | "local" | VisualOptions;           // measured visual checks (see Measured visual checks); default "local" in GenClass.init
                                                        // in a browser (on for development hosts and with the devtools overlay), off in createRuntime
  autoState?: boolean | { react?: boolean; redux?: boolean; zustand?: boolean; pinia?: boolean };
                                                        // automatic state discovery: default on in @genclass/runtime/auto* and the script tag,
                                                        // off in GenClass.init / createRuntime (there it needs import "@genclass/runtime/discover" first)
  token?: string;                                       // (0.3.0) app token, gc_ + 22 letters/digits: groups telemetry under your private dashboard
  analytics?: boolean;                                  // (0.3.0-beta.1) [false] interaction analytics for the app's dashboard: page views, engaged time,
                                                        // scroll depth, clicks, rage and dead clicks per element key; visits, sources, devices
                                                        // (Unreleased); navigation within a page load (how each view was reached, the previous route,
                                                        // the first interaction, the end and the path); needs telemetry and the token
  scope?: "app" | "functions";                          // (0.3.0) ["app"]; "functions": decisions and standing questions only for activity inside protect()ed functions (rt.ask() still answered)

  // batch 12 (docs/runtime/OPTIONS-SPEC.md); defaults in brackets
  enabled?: boolean | (() => boolean | Promise<boolean>) | { get(): boolean | Promise<boolean>; subscribe?(cb: () => void): () => void };
                                                        // [true]; false: nothing installed, model never downloaded; a predicate/source forces preload "lazy"
  sample?: number;                                      // [1] fraction of sessions that act (stable per session); the rest observe
  routes?: { match: string | RegExp | ((route: string) => boolean); mode?: Mode | "off"; aggressiveness?: Aggressiveness }[];
                                                        // first match wins; demote only
  requests?: {
    ignore?: RequestMatcher[];                          // not observed at all (native pass-through)
    protect?: (RequestMatcher | `preset:${string}`)[];  // observed, never held/retried/cached/discarded, nor what its response causes;
                                                        // a throwing predicate = protected; "preset:payments" / "preset:auth" = protectPreset(...)
    crossOrigin?: "observe" | "ignore";                 // ["observe"]; cross-origin is always passive
    labels?: { match: RequestMatcher; label: string }[];// names for reports/sinks ([A-Za-z0-9 _-], ≤ 5 words, ≤ 40 chars)
    labelsToModel?: boolean;                            // [false] labels appear in situation text only when true
    correlate?: (r: { url: string; method: string; headers: Record<string, string> }) => string | undefined; // redacted, ≤ 128 chars
  };
  breaker?: { undos?: number; errorsAfterAction?: number; attributionMs?: number; windowMs?: number; downgradeTo?: "observe" | "guard"; persist?: "session" | false } | false;
                                                        // [{2, 3, 5000, 600000, "observe", "session"}]
  shadow?: "guard" | "heal" | false;                    // [false] dry-run gate at a higher mode; Decision.shadow, "shadow" event
  onBeforeAction?: (a: ActionRequest) => boolean | void;// final sync veto (false or throw); counts against the hold budget
  vetoMode?: "enforce" | "report";                      // ["enforce"]; "report" records would-veto and runs the action
  sinks?: (SinkFn | { send: SinkFn; kinds?: SinkKind[]; sampleRate?: number; evidence?: boolean; possible?: boolean; flush?(): Promise<void> })[];
                                                        // possible (0.3.0) [false]: also "possible" detections
                                                        // adapters: @genclass/runtime/sinks ("See it where you already look" below)
  session?: { id?: string; tags?: Record<string, string | number | boolean> }; // never shown to the model
  audit?: { size?: number; sink?: (e: AuditEntry) => void }; // [1000 kept in memory] the audit trail (rt.audit()); sink: your own logging
  redact?: (path: string, value: unknown, kind?: "state" | "url" | "header" | "input") => unknown; // runs after built-in redaction; a throw → "[redacted]"
  report?: "console" | "interventions" | "silent" | ((r: Report) => void);    // "interventions": console prints interventions, undos, breaker only
  learn?: { persist?: boolean | "local" | "session"; key?: string; version?: string; maxBytes?: number; // persist [unset: profiles in memory; flows in localStorage when expectations are on; true/"local"/"session": both; false: nothing]; key ["genclass:learn"]; version [session.tags.release]: mismatch discards; maxBytes [200000]
            expectations?: boolean; minSupport?: number; minShare?: number; minSessions?: number }; // [false: opt-in until its false-report rate is measured], [8 in one page load, 20 with stored counts], [0.95], [3 with localStorage, else 1] (docs/runtime/EXPECTATIONS.md)
  // model: also loadIf [{ minDeviceMemoryGB: 4, saveData: "skip" }: no model on devices reporting ≤ 2 GB or on Save-Data; () => true forces],
  //        inlineFallback [true; false: a failed worker means no model, never the main thread], threads ["auto"], timeoutMs [10000],
  //        maxDecisionsPerMinute [30], unloadAfterIdleMs [false]
}

Runtime additions: rt.audit(n?) (the audit trail, below), rt.disable({ undo? }) (permanent; undo: true rolls back the last minute's actions), rt.summary(): SessionSummary (0.3.0: possibleDetections per diagnosis next to detections, the likely ones; after 0.3.0-beta.1: requests, below), rt.setSession({ id?, tags? }), rt.breaker.{tripped, reset()}, rt.learn.clear(), rt.learned() (below), events shadow, breaker, limit, modelBudget, expectation. status adds effectiveMode, sampled, breaker, scope, modelBudget, and states "disabled" | "skipped" | "unloaded". status.scope is what is in force on the current route: mode is the effective mode there (equal to effectiveMode: the requested mode demoted by sampling, the breaker and the matching routes[] rule) and aggressiveness the effective level; with a matching rule it also has rule (its index) and ceiling (the mode that rule caps the route at; rules only lower the mode). Before 0.2.0, scope.mode was that ceiling ("heal" when no rule matched). rt.gates() carries mode (the same effective mode). status.blocked (state "error"): { url, origin, csp, directive? } when the browser blocked a model or ORT download. status.sha256 (state "ready"): the sha256 from model.json that the loaded variant was verified against. status.reason (state "skipped", since 0.3.0): "device-memory", "save-data" or "loadIf" when model.loadIf kept the model from loading on this device (the default is the exported DEFAULT_LOAD_IF, { minDeviceMemoryGB: 4, saveData: "skip" }), and "worker-unavailable" when the model worker could not start and model.inlineFallback is false (status.workerError says why). The runtime keeps observing without a model and prints one console line either way.

Gate order (each request/decision): protected → cross-origin → op scope (created under off/observe) → mode/allow/deny/requireDiagnosis (at the effective mode) → thresholds → policy.actionLimits (limit:perMinute|perSubject|perSession; defaults 60/min global, 10/min per subject, 200 per session) → onBeforeAction (vetoed, would-veto, or limit:hold past the budget) → execute. effectiveMode = min(mode, sample cap, breaker cap, route rule); URL overrides (?genclass, ?genclass-mode, ?genclass-aggr, ?genclass-sample, localStorage.genclass) only demote, from the app's mode or from observe when it sets none; they may raise only with debug: true or on a development host (localhost, *.local, *.test, private networks). Before 0.2.2, ?genclass=guard opted a visitor into guard when the app set no mode. policy.holdBudgetMs is a hard ceiling that includes defers, the wait for a response or request body, a held write's deferred re-decisions and the veto hook.

Protected requests (requests.protect) are never held, never wait for their body to be read, and neither they nor anything their response callbacks cause (writes, timers, follow-up requests: the ops inherit the protection) are acted on. Presets for money and identity flows: import { protectPreset, PROTECT_PRESETS } from "@genclass/runtime"; protectPreset("payments", "auth") returns fresh RegExps (payments: checkout, payment(s), payment intents, billing, invoices, charges, refunds, subscriptions, orders, purchases, transactions, transfers, payouts, wallets, Stripe, PayPal, Braintree, Adyen, Klarna, Square; auth: login/logout, sign-in/up/out, register, oauth, tokens, sessions, SSO, SAML, OIDC, MFA, 2FA, OTP, passwords, verify). Recommended for payment, checkout and sign-in flows in every mode (packages/runtime/INTERCEPTION.md, "Money and identity flows"). Hidden tabs release held items unevaluated; in guard and heal they skip background evaluation, and in observe (0.3.0, where the possible tier is in force) each salient situation is built at once and decided when the tab is visible again, reported at the possible tier at most (at most 32 wait; a hidden tab never loads or reloads the model; OPTIONS-SPEC §5.1, §5.3).

Performance: GenClass computes cheap facts for every write and request, and asks the model only about salient ones. The situation the model reads is sized to the device (situation.budget: "auto"): 2,400 characters (about 1,000 tokens) on WebGPU, 2,000 on 4-thread WASM (crossOriginIsolated pages), 1,000 on single-thread WASM; at 1,400 characters or less the questions are compact (bare diagnosis labels and action names). State writes are never held by default. Held responses, messages and requests wait at most the hold budget (policy.holdBudgetMs: "auto": 1.5 × the model's recent median latency, 150 to 800 ms), then proceed unchanged; nothing is held when the model is not expected to answer within that budget. Serving your page with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp enables WASM threads (about 3× faster without WebGPU).

Self-hosting the model: npx @genclass/runtime@beta fetch-model public/genclass-model then GenClass.init({ model: { baseUrl: "/genclass-model/" } }).

State

GenClass protects state it can see. Register stores with atom (owned by GenClass), guard (owned by you) or an adapter (Redux, Zustand, React hooks).

Automatic state discovery (autoState, on with the one line: import "@genclass/runtime/auto" or the script tag) finds React component state (useState, useReducer, useSyncExternalStore, class state; through the React DevTools hook), Redux / Redux Toolkit stores (through the Redux DevTools compose and enhancer globals) and Zustand devtools stores (through the Redux DevTools connect API; Zustand disables devtools in production builds unless enabled: true is passed, so such builds are not discovered). Each write is recorded with the operation that made it. Discovered Redux stores get the Redux adapter (controllable, kind adapter). Discovered React and Zustand state is observed only (kind observed): it feeds facts, triage, detections and delivery decisions (deliver / defer), but GenClass never holds, drops, reverts or rolls back its writes, so discard and rollback are not offered for it. React stores are named after the component (SearchPage.state0, SearchPage.external0, class state keys; in production builds after the element the component renders). Opt out with autoState: false or <meta name="genclass" content="autostate=off">; ?genclass=off installs nothing. With GenClass.init, import @genclass/runtime/discover before React / Redux / Zustand and pass autoState: true.

rt.atom<T>(name: string, initial: T, opts?: StoreOptions<T>): Atom<T>
rt.guard<T>(name: string, io: { get(): T; set(v: T): void; subscribe?(fn: () => void): () => void }, opts?): Guarded<T>
rt.expect(name: string, predicate: () => boolean): () => void   // developer invariant, checked at settled points

interface Atom<T> {
  readonly name: string;
  get(): T;
  set(next: T | ((prev: T) => T)): void;
  update(fn: (prev: T) => T): void;
  subscribe(fn: (v: T) => void): () => void;
}
interface StoreOptions<T> {
  resync?: () => Promise<unknown> | unknown;   // "reload this store from its source": enables the resync action
  hold?: boolean;                              // default true; false: writes are never held
  describe?: (v: T) => string;                 // one-line description used in situations
}
const search = rt.atom("search", { query: "", results: [] as Item[] }, {
  resync: async () => search.set({ ...search.get(), results: await api.search(search.get().query) }),
});
input.oninput = () => search.set((s) => ({ ...s, query: input.value }));   // user write: applied at once
const res = await fetch(`/api/search?q=${q}`);                          // a stale response may be held briefly
search.set((s) => ({ ...s, results: data }));                           // applied at once (stale fields may be dropped)

How writes flow: GenClass decides at the network boundary and enforces synchronously at the store. A response (fetch or XHR) or a pushed message (WebSocket, EventSource) is checked before the app sees it: GenClass predicts which fields it will write (from what the same operation wrote before) and consults the model only when it would overwrite newer data (an operation that started later already wrote it, and no newer request of the same kind is still on its way), put back the value of an unconfirmed local change (a user action's optimistic write whose own request is still in flight), or replace text the user typed meanwhile. The model may let it through (deliver), let it through but drop the writes it makes over newer data (discard: each later set() of that response's chain applies its other fields and skips those), or hold it until the related requests finish and decide again (defer). Holding only adds latency; messages of one socket or stream keep their order. Before asking, it reads the response (a clone, ≤ 256 KB of JSON) and compares it with the store: a response that equals the current values changes nothing and is let through without a model call. The situation then says what the response would do: put back a value a newer operation replaced (per field, or per item cell of a list joined by id), replace text the user typed since the request started (with a preview centred on the difference), or reload a list without the item a recent create returned.

set() always applies at once (set(x); get() returns x) and notifies subscribers; writes to one store apply in the order they were made. A write that looks wrong on its own (not covered by a delivery decision) is decided in the background: if the model answers discard within 2 s after the write applied, nothing has overwritten it and the same operation made no other write since, GenClass reverts exactly that write (a "late revert", reported as such and undoable).

policy.holdWrites: true (opt-in) holds salient writes until the model answers (at most the hold budget): a later write to the same store first applies the earlier held ones in order, and get() inside the writing operation returns the pending value. An updater that changes the stored value in place never changes live state before the decision (GenClass works on a detached copy, or applies at once when it cannot restore the live value exactly); a functional update runs again on the value at apply time; a held value write is re-applied as a patch of the fields it changed. Reads that bypass GenClass (a Redux middleware's store.getState()) do not see held writes: keep holdWrites off for such stores or pass hold: false.

GenClass also learns, at settled points (no requests in flight, settleMs of quiet), generic relations between your fields (a == b for fields with related names, a == len(B), a == sum(B[*].f), a == sum(B[*].f * B[*].g), a == count(B[*].k == v), a >= 0, a ∈ B[*].k, B[*].id unique, stable types, non-null) and each operation's usual effects on state (after about 20 completions of the same operation; kept between visits only with learn.persist, see below). When a learned relation breaks, or an operation changes state unlike it normally does, the model is consulted. To avoid false alarms on correct apps: a selection holding 0, -1, "" or null means nothing is selected; uniqueness is only learned for id columns; pagination metadata (page, offset, limit, cursor, and a total next to them) never enters a relation; stores you are typing into are checked once typing pauses for a second; counters that change on nearly every write are left out unless they are a learned sum or count of a list.

Learned flows and expectations

Opt-in in 0.3.0 (learn: { expectations: true }): off by default until its false-report rate on correct apps is measured (docs/runtime/RESULTS.md, "Flow expectations"). Off, nothing below is learned, stored or reported, and its code (src/learn/flows.ts) is never loaded; on, the runtime imports it on the next task after init (a 0 ms timer on the injected clock; a lazy chunk, so it adds nothing to the first load) and learns from then on.

For every user action on a route (a click, a submit, a change, Enter; never typing) GenClass learns which requests it starts and how soon, which request follows which, what each response writes, where the page navigates and how long the flow takes to settle. Once a step was seen in at least 95% of 20 or more observations spread over 3 or more visits (with the default persistence; 8 or more observations in one page load when nothing is stored: learn.minShare, learn.minSupport, learn.minSessions), a flow in which it did not happen is reported:

[GenClass] Expected a request on /cart: clicking button "Add to cart" usually sends POST /api/cart (41 of 41 times across 6 sessions, within 0.4 s); this time nothing was sent in 2.0 s.
[GenClass] Expected a follow-up on /checkout: after POST /api/orders completes, clicking button "Place order" usually sends GET /api/orders (40 of 41 times, within 0.1 s); this time nothing followed in 1.0 s.
[GenClass] Expected a state change on /cart: the response to POST /api/cart after clicking button "Add to cart" usually writes cart.items (41 of 41 times); this time it wrote nothing.
[GenClass] Expected a navigation on /checkout: clicking button "Place order" usually leads to /confirmation (41 of 41 times, within 1.2 s); this time the page stayed on /checkout for 3.0 s.
[GenClass] Expected a response: GET /api/items usually answers within 0.4 s (p99 of the last 64; 20 of 20 recent requests answered); this one has been waiting 2.1 s (bound 1.0 s).

These are deterministic reports from learned counts: the model is not consulted and nothing is held, retried or changed in any mode. Only a request that changes data (not GET, HEAD or OPTIONS) is expected to start or to write after its answer; a step is checked only on the branch of the handler it was learned on (the same fields written, so a submit that showed a validation error and sent nothing is not a gap); a repeat of the action within a page load is held to what earlier repeats did. They are skipped while the page is hidden or offline or the runtime is paused, for requests under requests.protect, for answers with status 400 or above, for the second click of a double click, for disabled controls, and for an action repeated while its flow is still open. Deadlines come from the learned delays (twice the p95, at least 1 s; a response needs twice the p99, at least 1 s, and the third visit with persistence). A step reported missing is not expected again until it is seen again, so an app whose behaviour changed gets one line, not one per visit, and a flow's counts are halved at 64 observations, so what the app does now outweighs what it did long ago. The counts are illustrations of the format, not measurements. Design, false-positive controls and what v2 may do with them: EXPECTATIONS.md.

rt.learned(): Learned                 // { route, expectations, session, flows: LearnedFlow[], deviations: Expectation[], relations }
rt.on("expectation", (e: Expectation) => void)   // also a Report of kind "expectation" (report option, "report" event, sinks)
GenClass.init({ learn: { expectations: true } })  // opt in (default false: learn and report nothing of this)

Sinks. An expectation record reaches a sink that forwards every kind (a function, { send } without kinds, the webhook and console-json adapters) only while the layer is on; a sink whose kinds leaves it out never gets it.

Persistence. With learn.expectations on, the learned flows (they only produce reports) are kept per origin in localStorage by default; transition profiles, which the model reads, stay in memory unless learn.persist is set (true or "local": both in localStorage; "session": both in sessionStorage; false: nothing is stored). Nothing is stored by default in a DOM test environment (jsdom, happy-dom) or when storage does not work. The value lives under learn.key (genclass:learn), is discarded when its format, the runtime version or learn.version (default session.tags.release) changes, is capped at learn.maxBytes (200,000 characters; the most-used flows and largest profiles are kept, and only the stored copy is trimmed), is written 5 s after a change, when the page is hidden or unloads and on rt.destroy(), and is cleared by rt.learn.clear(). A tab's sessionStorage keeps its session ordinal (genclass:learn:session), so a reload is not a new visit. It holds request signatures, routes and field names (id-like path segments and keys normalised, other segments and map keys as they are, secret-named fields never), counts and delay samples; user action labels are stored only as hashes, and values never. The devtools overlay shows what was learned under its Expectations tab.

Operations and user actions

The DOM, fetch, XHR, WebSocket and timers are observed automatically. These entry points cover anything else:

rt.op<T>(name: string, fn: () => Promise<T> | T, meta?: Record<string, unknown>): Promise<T>   // a named async task
rt.user<T>(action: UserAction, handler?: () => T): T | undefined   // a user action (cause of what follows)
rt.emit(name: string, data?: Record<string, unknown>): void        // a custom event in the timeline
rt.reportError(error: unknown, info?: { source?: string }): void   // a handled error worth knowing about

interface UserAction { kind: "click"|"type"|"change"|"submit"|"key"|"nav"|string; target?: string; value?: string; key?: string; sensitive?: boolean; clicks?: number /* MouseEvent.detail */ }
await rt.op("loadProfile", async () => profile.set(await (await fetch("/api/me")).json()));
rt.user({ kind: "click", target: 'button "Sync"' }, () => startSync());

The DOM observer describes the element the user actually interacted with, also inside open shadow roots (Lit, custom elements): button "Send reply", select "Status". Names come from aria-label, aria-labelledby, the label (its own text, without the options or values of controls nested in it), rendered text (following slots), placeholder, title, name, or the custom element host's label/aria-label.

Causality is tracked through await on a best-effort basis: the operation that is running is "ambient" while a user handler or rt.op body runs, after a fetch/XHR settles and when its body (json(), text(), ...) is read, and inside timers scheduled during an operation; it is cleared at the end of the task. Writes record the ambient operation as their cause; operations started while another is ambient get it as their parent.

Protected functions

0.3.0 (not in 0.2.x).

import { GenClass, protect } from "@genclass/runtime";
GenClass.init({ token: "gc_...", scope: "functions" });

const submitOrder = protect("checkout submit", async (cart: Cart) => { /* fetch, writes, ... */ });

protect(name, fn) returns a function with fn's signature. Each call runs as a "task" operation named name (like rt.op), so what it starts is linked to it, and decisions about that carry Decision.fn = name (the outermost protected function when they nest; also fn on telemetry decision events when a token is set, as "[redacted]" when the label looks like data: an e-mail address, a URL, 4+ digits in a row, 12+ hex digits). this, arguments, return value and thrown errors pass through; a sync function stays sync; an async function's promise is replaced by one that settles the same way; other thenables are returned untouched. It looks up GenClass.runtime on every call and calls fn directly when there is none. It never throws on its own. With scope: "functions" everything is still recorded, but decisions are raised only for subjects inside a protected call, and standing questions (rt.question(), plugin questions) are asked only about them (explicit rt.ask() / rt.decide() are answered as before). For an async fn the operation and the situation are the same as with rt.op(name, fn); a sync fn's operation ends when it returns. There is no per-function mode option yet.

Asking the model

rt.ask<Q extends Question>(q: Q, opts?: { about?: "now" | number /* op id */ | string /* store */; timeoutMs?: number }): Promise<AnswerOf<Q>>
rt.decide<L extends string>(question: string, options: Record<L, string>, opts?): Promise<L>

The question is answered against the current situation (what the app is doing now, or about one op or store).

const mode = await rt.decide("How should the editor save right now?", {
  now: "save immediately, the user paused",
  later: "wait, the user is still typing quickly",
});
const busy = await rt.ask({ type: "score", instructions: "How busy is the app right now?", criteria: ["idle", "light", "busy"] });
const risky = await rt.ask({ type: "noul", instructions: "Did the last checkout request fail?" });   // risky.noul = P(true)

ask/decide wait for the model to load (bounded by timeoutMs) and reject with GenClassUnavailableError (reason: "off" | "error" | "timeout" | "destroyed") when no model can answer.

Question shapes (Jev wire format):

type Question =
  | { type: "noul"; instructions: string; criteria?: { true?: string; false?: string } }
  | { type: "choice"; instructions: string; criteria: Record<string, string | null> }
  | { type: "score"; instructions: string; criteria: string[] };
// AnswerOf: noul -> { noul }, choice -> { choice, confidence, probabilities }, score -> { score, confidence, probabilities }

Events

rt.on("detect", (d: Detection) => void)      // the model diagnosed a problem: d.detection "likely" (diagnosis != expected, p >= the report gate);
                                             // "possible" ones (0.3.0: p >= gates().possible, below the report gate) only with detect: "possible"
                                             // set explicitly (every decision, with detection set, is on "decide")
rt.on("decide", (d: Decision) => void)       // every model decision
rt.on("act", (a: ActionRecord) => void)      // every non-passive action that ran
rt.on("event", (e: RtEvent) => void)         // every traced event
rt.on("status", (s: ModelStatus) => void)    // model loading progress and state
rt.on("report", (r: Report) => void)         // every report line (even with report: "silent")
rt.on("expectation", (e: Expectation) => void) // a learned step did not happen (see "Learned flows and expectations")
rt.on("check", (f: CheckFinding) => void)    // a measured visual check was confirmed (never fires "detect")
// each returns an unsubscribe function

Introspection and control

rt.status: ModelStatus             // { state: "off"|"loading"|"ready"|"error", progress?, device?, variant?, threads?, phase?, error?, ... }
rt.ready: Promise<void>            // resolves when the model is ready (immediately with no model); reading it starts a lazy load
rt.mode: "observe" | "guard" | "heal"
rt.situation(trigger?): Situation  // what the model would see: { trigger, subject, state, questions, actions, salient, facts }
rt.explain(id): Explanation | null // a decision ("d3") or action ("a1") id -> { message, decision, situationText, facts, timeline, answers, action?, changed? }
rt.holdBudgetMs(): number          // the current hold budget
rt.situationBudget(): number       // the current situation size in characters
rt.gates(trigger?): EffectiveGates // the gate thresholds in force (see Policy); `possible` when observe's possible tier is in force
rt.history(n?): RtEvent[]          // recent events, oldest first
rt.decisions(n?): Decision[]       // last 200 decisions
rt.interventions(n?): ActionRecord[]
rt.audit(n?): AuditEntry[]         // the audit trail, oldest first (see Reports, explain and undo)
rt.inflight(): Op[]
rt.stores(): StoreInfo[]           // registered and discovered stores: { name, kind: "atom"|"guard"|"adapter"|"observed", source?, writable, fields, version }
rt.learned(): Learned              // learned flows per user action and route, recent deviations, learned relations
rt.checks(n?): CheckFinding[]      // the last measured visual check findings (at most 200; empty while the checks are off or not loaded)
rt.checkNow(): Promise<CheckFinding[]> // run a measured visual check pass now (loads the checks if needed) and resolve with what it confirmed
rt.setMode(mode); rt.pause(); rt.resume(); rt.destroy()
rt.telemetry?: { enabled; reason?; endpoint?; sessionId?; flush(): Promise<void> } // reason when off: option, headless, url, localStorage, gpc, sampled-out, kill-switch, disabled, ...
rt.analytics?: { enabled; reason?; start(): boolean; stop(): void } // interaction analytics (0.3.0-beta.1); reason when off: option, telemetry-off, no-token, stopped, error

pause() stops consulting the model (everything proceeds unchanged, tracing continues); destroy() uninstalls every observer and restores the globals it wrapped.

Triggers and actions

trigger when actions (passive first)
delivery a response (fetch/XHR) or a WebSocket/EventSource message is about to reach the app and, judged from its body (read from a clone), would change newer data already applied (no newer request of the same signature in flight), put back the value of an unconfirmed local change, or replace text the user typed since the request started deliver, discard, defer
mutation a salient state write not covered by a delivery decision (decided in the background unless holdWrites) apply, discard, defer
request a fetch/XHR is about to be sent send, coalesce, delay, block, serve_cached
failure a request failed (network, timeout, 5xx, 429, 408) before the app sees it deliver, retry, serve_cached
stall a request is far past its usual latency, or has been in flight 10 s without a latency baseline yet wait, hedge, serve_cached
inconsistency a learned relation between fields broke ignore, rollback, resync
transition an operation changed state unlike it usually does ignore, rollback, resync
error an uncaught error or unhandled rejection ignore, rollback
action tier effect
discard guard delivery: deliver it, but drop the state changes its operation makes over newer data (ActionRecord.dropped lists them; undo: apply them now). mutation: revert exactly that write if decided ≤ 2 s after it applied, nothing overwrote it and its operation wrote nothing else since (undo: re-apply); with holdWrites, drop the held write
defer guard hold the response/message (or held write) until related requests finish, then decide again (twice at most)
coalesce guard do not send; reuse the response of the identical request in flight or just finished (x-genclass: coalesced)
delay guard wait min(250 ms · 2^failure streak, 8 s), then send
block heal do not send; answer 503 (x-genclass: blocked)
serve_cached heal answer with the last good response for this GET (x-genclass: cached; ≤ 256 KB, ≤ 64 entries, memory only)
retry heal re-send after min(200 ms · 2^(attempt-1), 5 s) when the body can be replayed and repeating is safe by HTTP semantics: GET, HEAD, OPTIONS, PUT, DELETE, or another method (POST, PATCH, ...) only when the request carries an idempotency key header (policy.idempotencyHeaders; request ids and tracing headers are not keys) or, opt-in, a JSON body field named in policy.idempotencyBodyFields
hedge heal send a second identical GET and use whichever answers first
rollback heal inconsistency: restore the involved stores to their last consistent snapshot; error/transition: restore only the fields the operation's own chain wrote to their earlier values (undo: restore the replaced values)
resync heal call the store's resync handler

Diagnoses the model chooses from (vocabulary.diagnoses replaces them; plugins add labels): expected (normal behaviour), stale (outdated data about to replace newer state), conflict (concurrent operations competing), duplicate (the same change or request again without a new intent), inconsistent (state contradicts itself), failing (keeps failing), slow (far slower than usual), overload (triggered far more often than usual), unusual (unlike how the same operation normally behaves), transient (a one-off failure likely to succeed if tried again).

Actions are offered only when they apply (situation().notOffered lists the built-in actions left out and why; an identical request exists, a cached response exists, the body can be replayed, a consistent snapshot exists, a resync handler exists, the failing operation wrote state). Responses are always cloned before the app reads them. GenClass's own requests and writes are never gated; keepalive requests and synchronous XHRs are never held; nothing is held when the mode and policy permit no action for it or when the model is not expected to answer within the hold budget. A queued decision whose subject was superseded (the response was released, the write overwritten, the request aborted) runs behind fresh ones; in guard and heal it is skipped when decisions take over 300 ms (it could delay a held one), in observe (0.3.0) it is always computed. In observe a queued decision also gains one priority level per second it waits, up to the background level, so error and transition decisions are not starved by a burst of writes. XHR: the app's completion listeners (readystatechange, progress, load, loadend, and the matching on* handlers) are wrapped so a held response reaches them, in order, once delivered; on* getters return that wrapper.

Two requests are "identical" when method, URL, headers (tracing ids such as traceparent or x-request-id excluded) and body content match. Bodies GenClass cannot read cheaply (streams, files, bodies over 64 KB) never match anything.

Policy

interface PolicyOptions {
  thresholds?: { report?: number; guard?: number; heal?: number };  // overrides; else the model's own gate, else 0.6 / 0.9 / 0.8
  allow?: string[];                 // only these non-passive actions may run
  deny?: string[];                  // these never run
  holdBudgetMs?: number | "auto";   // default "auto": clamp(1.5 × median recent model latency, 150, 800) ms
  holdUserWrites?: boolean;         // default false
  idempotencyHeaders?: string[];    // default ["Idempotency-Key", "X-Idempotency-Key"]: a POST/PATCH carrying one may be retried
  idempotencyBodyFields?: string[]; // opt-in, default []: top-level JSON body fields (e.g. "request_id") the server dedupes on
  holdWrites?: boolean;             // default false: state writes apply at once (decided at the network boundary)
  maxActionsPerMinute?: number;     // default 60
  requireDiagnosis?: boolean;       // default true
}

Thresholds: the model's action probabilities are calibrated against the gate it ships with (gate in its meta.json, visible as runtime.status.gate). Two gate kinds exist:

  • kind: "mass" (the default, also when meta.json has no kind): the most probable permitted action runs when the summed probability of the permitted actions reaches its tier's threshold ({ report?, guard: { default, byTrigger? }, heal: {...} }, probabilities; defaults 0.9 / 0.8).
  • kind: "gain" ({ kind: "gain", tauGain, guard: { default, byTrigger? }, heal: {...}, report? }): for the most probable permitted action a, ĝ(a) = tauGain · ln(p(a) / p(passive)) estimates its gain over the passive action in cost units; a runs when ĝ(a) is above its tier's margin (defaults 2 / 2; tauGain default 1). Probabilities are clamped to ≥ 1e-6; when the model gives no probability for the passive action, the mass it left over is used. Aggressiveness (InitOptions.aggressiveness: "cautious" | "balanced" | "eager" | 0–1, default "balanced"; runtime.setAggressiveness(x); URL ?genclass-aggr=…) selects the gate: when meta.json has gate.profiles: { cautious, balanced, eager } (each a gate of either kind, report included), a named level uses its profile and a number interpolates thresholds/margins, report and tauGain linearly between the neighbouring profiles (the nearer profile's kind when they differ). Without profiles the single gate (or the defaults) is shifted: cautious +0.05 on thresholds / +1 on margins, eager −0.05 / −1 (linear in between, clamped). runtime.aggressiveness, runtime.status.aggressiveness and runtime.gates() (aggressiveness, level, levelSource) show the level. For each trigger kind the effective value is your policy.thresholds value when set, read in the active kind (probabilities for "mass", margins for "gain"; report is always a probability), else the model's value for that trigger kind, else its tier default, else the defaults. runtime.gates(trigger?) returns the kind, values and where each comes from (policy, model, default); every decision records gateKind, and threshold (mass) or gain and margin (gain), with thresholdSource; explain(id).gates has the full set.

The permitted actions are the applicable non-passive actions the mode allows (observe: none; guard: guard tier; heal: both), minus denied ones (only allowed ones when allow is set). GenClass runs the most probable permitted action only if all of these hold, otherwise the passive action runs and the decision's reason says why: the summed probability of the permitted actions reaches that action's tier threshold; the model's top diagnosis is not expected (unless requireDiagnosis: false); fewer than maxActionsPerMinute actions ran in the last minute; the decision arrived within the hold budget (a late discard may still revert the write, see State). While the model is loading, everything proceeds unchanged: guard and heal build no situation then, as before 0.3.0; in observe (0.3.0, where the possible tier is in force) a salient situation is built at its trigger and decided once the model is ready, for detection only and at the possible tier at most (at most 32 wait, oldest dropped and counted as not-ready; a lazy model starts loading at the first one, and an idle-unloaded one, model.unloadAfterIdleMs, reloads, from a visible tab only). Such a deferred decision never acts, whatever the mode is by then. The first salient situation after an idle unload fails open as not-ready in guard and heal and starts the reload.

Reports, explain and undo

With report: "console" every detection and intervention prints one line, followed by a collapsed group with the evidence: the facts, the timeline, the exact situation text sent to the model, the answer probabilities, what changed, how to undo it and how to deny that action. Identical repeats within a minute are folded into one line printed when the minute ends ("×N more in the last minute"). A detection that did not act says what GenClass would have done and why not.

Detections have a tier (0.3.0, InitOptions.detect, Decision.detection). Likely: the top diagnosis is not expected and its probability is at least the report gate (rt.gates().report: the aggressiveness profile's report value from the model's meta, 0.90 at balanced with model 0.2.0, or policy.thresholds.report); printed as above. Possible (by default only where the effective mode is observe and policy.thresholds.report is unset; detect: "possible" turns it on elsewhere, detect: "likely" off): at least the eager profile's report value from the same meta (rt.gates().possible, 0.70 with model 0.2.0) and below the report gate. A possible detection is the same line ending in (possible), printed with console.debug only (shown at Chrome's "Verbose" level), and its evidence is in rt.explain(id); it reaches the devtools as a dimmed card left out of the counts (no pulse), rt.summary().possibleDetections, telemetry's detect event with tier: "possible", and rt.on("decide") with decision.detection: "possible". It reaches rt.on("detect"), rt.on("report") and a report function only when detect: "possible" is set explicitly (so an app wired to them gets what it got in 0.2.x), and sinks only with possible: true. A situation decided late (deferred while the model loaded or the tab was hidden, OPTIONS-SPEC §5.3) is reported at the possible tier at most. explain(), the console, the devtools and telemetry all read the tier recorded with the decision. The tiers are reporting only: no action gate reads them.

[GenClass] Prevented a stale response: search.results was written twice by other operations since its operation (#6) started (version 1 → 3), last 0.69s ago by GET /api/search?q=reac (#8), which started 0.09s after #6, from a later user action (#7). Delivered the response to GET /api/search?q=rea (#6) and dropped the state changes it makes over newer data (search.results). (stale, 0.97; discard 0.97)
const a = rt.interventions().at(-1)!;
a.changed;          // "Delivered the response to GET /api/search?q=rea (#6) and dropped the state changes it makes over newer data (search.results)."
a.dropped;          // ["search.results"]
a.undo?.();         // discard: applies the dropped values now; late revert: re-applies the write; rollback: restores the values it replaced
rt.explain(a.id);   // { decision, situationText, facts, timeline, answers, action, changed }

A custom report function receives { kind: "detect" | "intervene" | "status" | "undo" | "breaker" | "error" | "expectation" | "check", message, decision?, action?, expectation?, check? }. explain(id).message is the same line for any decision or action, also when it was not printed. An expectation report (a learned step that did not happen) carries no decision: its evidence is the learned counts in report.expectation and the console group prints them. A check report (a measured visual check, see "Measured visual checks") carries no decision either: report.check is the finding.

Your own debug UI: GenClass ignores user events from inside any element marked data-genclass-ignore (the devtools overlay uses it), so debugging tools never become causes in situations.

See it where you already look

0.3.0 (CHANGELOG): the adapters, the @genclass/runtime/sinks exports, sinks by name and init --sink are not in 0.2.x. The runtime has no Sentry, PostHog or Datadog adapter. On every genclass.dev plan, Free included, the GenClass collector forwards detections to those tools once you turn it on (https://genclass.dev/pricing).

sinks receive a SinkRecord per report (OPTIONS-SPEC §4.11): kind, id, ts, sessionId, tags, mode, sampled, diagnosis, p, detection (kind detection: "likely", or "possible" for a sink with possible: true), action, tier, trigger, label, route (the page path the subject's operation was created on, normalised as telemetry sends it: id-like and email segments as :id, at most 200 chars), changed (paths), reason, shadow, correlationId, and with evidence: true the redacted message, subject and timeline (URL query and fragment values as …; timeline lines can hold short fragments of app data, as the console does: a store value summary, an error message, text typed into an ordinary input). Two vendor-neutral adapters keep the records on your side, with no dependencies, each a function returning that sink shape, never throwing, and sending nothing beyond the record. Each takes kinds, sampleRate, evidence (default true; false sends the structured fields only) and possible (0.3.0, default false: likely detections only; sinks by name never get possible ones):

import { consoleJson, webhook } from "@genclass/runtime/sinks"; // or @genclass/runtime/sinks/<name>

consoleJson({ method: "log", kinds });
// console.log('{"source":"genclass","schema":1,"kind":"detection",...}'), one line per record, every kind (summary too).

webhook({ url: "/genclass", maxBatch: 50, maxPerSignature: 10, headers, kinds, sampleRate, clock, flushMs: 5000 });
// POST { schema: "genclass-sink/1", version, records: SinkRecord[] } by navigator.sendBeacon, else fetch keepalive, as JSON
// in a text/plain body; at most one POST per idle-time delivery round (a microtask after it; with a clock: flushMs after
// the first record, or at once at maxBatch), holding at most maxBatch records and 32,000 UTF-8 bytes of them (about half
// the page's 64 KiB keepalive budget, which the app's own beacons share). The rest waits, oldest first, for the next
// round (with a clock: flushMs later; without: the next records' round, or the previous fetch settling); on page hide
// one batch goes, the session summary first, and what is left is dropped. At most 500 records wait. The records and
// nothing else (route, diagnosis, action, ..., the evidence text above): never the situation text, bodies, headers or
// cookies. With headers (fetch only) or no sendBeacon, list the URL in requests.ignore.

Both forward every kind (the summary included) except check (the measured visual checks), which a sink gets only when its kinds lists "check". webhook caps repeats of one bug (same kind, trigger, diagnosis, action and subject shape, with op ids, numbers and id-like path segments such as UUIDs, hex, tokens, slug ids and emails as one; for a check record, the same check kind and selector) at maxPerSignature 10 per page load, and never caps its summary record.

Sinks by name, with no code, in the zero-code entries (/auto, the script tag): window.GENCLASS_CONFIG = { sinks: ["webhook:/genclass"] }, <meta name="genclass" content="sink=webhook:/genclass+console-json"> (+ joins names, since commas separate the meta keys), data-sink="webhook:/genclass,console-json" on the script tag, or npx @genclass/runtime@beta init --sink webhook:/genclass (the names are console-json and webhook:<url>; console-json:noevidence makes its adapter with evidence: false). The names sentry, posthog and datadog (any case, with or without an option) are left out at init with one console warning for them all, Ignored sink <names>: Sentry, PostHog and Datadog forwarding is not in the free runtime: https://genclass.dev/pricing, and nothing is loaded or sent for them; init --sink sentry is a usage error that says the same. GENCLASS_CONFIG passes sink objects through. The names become one proxy sink at init that queues records (at most 200) and loads the adapters on the first record (never before: a page that reports nothing, or runs with ?genclass=off, fetches none): a lazy chunk of /auto, dist/sinks/<name>.js from the CDN for the script tag. Only the proxy is in the first-load bundle. A webhook URL is made absolute at init (against document.baseURI) and its endpoint (origin and path) joins requests.ignore as an exact match; from a <meta> tag it must be same-origin as the browser resolves it, against the page and document.baseURI (a cross-origin one is ignored with one warning, as for the model and ORT URLs). An unknown name, or an adapter that cannot load, is one console warning. A name handed to GenClass.init({ sinks }) directly is ignored with one warning: import the adapter there. The console default is unchanged.

Measured visual checks

Deterministic measurements of visual and CSS bugs that never throw an error, with fixed thresholds (VISUAL.md has every check, threshold and exclusion). They are reported as kind "check" and never as a model finding: the model is not involved, the checks never feed a situation, a fact, the event log, the gate or a decision, and they never fire detect. For any URL from a terminal or a coding agent, without code: npx @genclass/runtime@beta scan <url> [--json] (the package README's "Audit a page"; @genclass/mcp's genclass_scan).

GenClass.init({ visual: true });                                       // on everywhere, report-only
GenClass.init({ mode: "guard", visual: { on: true, fix: true } });     // with the three runtime fixes
rt.on("check", (f) => console.log(f.kind, f.selector, f.measured, f.suggestion));
await rt.checkNow();                                                   // a pass now, with its persistence re-checks

interface VisualOptions {
  on?: boolean | "local";            // ["local" in GenClass.init in a browser; false in createRuntime] "local": development hosts and the devtools overlay only
  checks?: Partial<Record<CheckKind, boolean>> & { stallMs?: number }; // [every kind on; stallMs 15000]
  fix?: boolean | ("text-invisible" | "pointer-events-leak" | "scroll-lock-stuck")[]; // [false] needs on: true and guard or heal
  ignore?: string[];                 // selectors never checked (and image hosts for text-placeholder)
  budgetMs?: number;                 // [8] per idle slice, clamped to 2..16
  passBudgetMs?: number;             // [100] check time per pass; the pass then ends as partial
  maxElements?: number;              // [2000] candidate elements per pass
  lowEnd?: "light" | "full" | "off"; // ["light"] low-end devices run only the near-free checks
  sample?: number;                   // [1] fraction of sessions that run the measured layout and text checks (hash of the session id)
  maxConsoleLines?: number;          // [10] console lines per pass, then one line
}

type CheckKind = "image-broken" | "image-css-broken" | "media-failed" | "stylesheet-failed" | "font-failed"
  | "icon-font-failed" | "csp-blocked" | "text-invisible" | "text-template-leak" | "text-placeholder" | "text-encoding"
  | "css-var-undefined" | "image-distorted" | "page-overflow-x" | "viewport-meta-missing" | "layout-shift"
  | "page-blank" | "click-blocked" | "pointer-events-leak" | "scroll-lock-stuck" | "control-unreachable"
  | "focus-obscured" | "dialog-covered" | "request-stalled" | "ad-heavy" | "text-warped";  // ad-heavy, text-warped: VISUAL.md 3.25, 3.26 (report only)

interface CheckFinding {
  id: string;                        // "c1", "c2", ...
  source: "check";                   // always: a measurement, never a model finding
  basis?: "measurement" | "baseline";
  kind: CheckKind; severity: "high" | "medium" | "low"; at: number; route?: string;
  target: string;                    // 'paragraph "Ship faster"'
  selector: string;                  // "p.hero__subtitle" (built locally; never sent anywhere unless your sink asks for evidence)
  element(): Element | null;         // the element while it is still connected
  measured: Record<string, number | string | boolean>;    // { ratio: 1.04, fg: "#fafafa", bg: "#ffffff", ... }
  threshold: { value: number | string; unit: string; source: string };
  context: { scheme: "light" | "dark"; state?: "rest" | "hover" | "focus" | "touch"; viewport: { w: number; h: number }; coarse: boolean; tier: "full" | "light" };
  message: string;                   // the console line
  details: string[];                 // the collapsed console group
  suggestion?: { selector: string; css: string; note?: string };
  fix?: { id: string; applied: boolean; css?: string; reason?: string; undo(): boolean };
  count?: number;                    // elements folded into this finding
}
  • Defaults. GenClass.init(), /auto and the script tag use "local": on for development hosts (http(s) on localhost, 127.0.0.1, [::1] or a *.localhost name with a port, and *.test names) and when the devtools overlay mounts, off everywhere else (Capacitor, Tauri, Electron and intranet hosts included). visual: true turns report-only checks on everywhere. ?genclass-visual=off turns them off; ?genclass-visual=on turns on report-only checks on any host for that page load (an app's explicit visual: false wins). A URL never turns fixes on. Zero-code: <meta name="genclass" content="visual=on"> (off, local, fix), data-visual on the script tag, window.GENCLASS_CONFIG = { visual: { ... } }.
  • When they run. Nothing loads until 1.5 s after load (6.5 s at most), while enabled is false or pending, or while the tab is hidden or the page prerenders, and nothing is measured in a hidden tab. Every page read runs in an idle callback, never in your event handlers. Then fonts, network quiet and DOM quiet (500 ms each, 10 s at most), one pass in idle slices, persistence re-checks 1 to 2 s later, and incremental re-checks of the subtrees that changed (a full pass at most every 30 s). Interaction checks run on clicks, hover, focus, anchor jumps, scrolling and a 2 s computed-style read of html and body. A route whose mode is "off" gets no checks.
  • Reports. The console line starts [GenClass] Check (measured): and ends with the suggested CSS (or what to change when CSS is not the fix). report: "silent" prints nothing, "interventions" prints only applied and undone fixes, a report function and rt.on("report") listeners receive every Report of kind "check" (r.check is the finding) without opting in.
  • Sinks get kind: "check" records (check: { kind, severity, fixed }, route) only when their kinds list "check"; evidence: true adds message, target and selector.
  • Audit. Every fix applied, undone or dropped is an entry of kind "fix": fix: { what, checkId, kind, css, reason? }.
  • Stalled requests. request-stalled (after 0.3.0-beta.1): a fetch or XHR request still pending after visual: { checks: { stallMs } } (default 15,000 ms, clamped to 1 s..10 min) is reported once, with its method, host and normalised path (no query), how long it has waited, and the control the page disabled (disabled, aria-disabled="true") or marked aria-busy="true" from 1 s before the request started, if it still is (severity high then, medium otherwise; measured.control is "not watched" for a request older than the checks). Not checked: EventSource and WebSocket, a request on a background schedule the runtime learned (long polling), an endpoint whose learned p95 latency is at least stallMs. visual.ignore takes a host or a path prefix. Needs the runtime (not in checkPage()); judged on the Clock at most every 2 s, from the start of the page (not after it settles).
  • Summary. rt.summary().visual, once the checks have run: checks (per kind), checksByRoute (at most 20 routes), fixes (applied, undone), passes, elements, ms, maxSliceMs, partial, abstained, unsupported (kinds this browser cannot measure), tier, stopped.
  • Fixes (visual: { on: true, fix: true }, guard or heal only): a readable text colour through one rule in an adopted stylesheet GenClass owns, and removing one leaked inline pointer-events: none or overflow: hidden on html or body. Re-measured 50 ms later and undone if the threshold is not cleared; a colour fix is measured again every 2 s, after class or style changes and on a colour-scheme change, and undone when its background changes; a scroll lock held since load with no dialog ever open is not removed; finding.fix.undo() (or the devtools Undo) undoes it and turns that kind off for the session (three undos turn all fixes off); undone automatically on setMode("observe"), the breaker, pause() (within 2 s, also in a hidden tab), disable() and destroy(); dropped, never fought, when the app changes the element. At most 50 fixes per page. Observe never changes the page.
  • In your tests. import { checkPage } from "@genclass/runtime/visual"; await checkPage(options?) runs one report-only pass in the current page without a runtime and resolves with the findings (interaction checks need a running runtime).
  • Devtools. A Checks tab lists every finding with its measured value and threshold, the suggested CSS (Copy), a Show button that scrolls to the element and outlines it inside the overlay, and Undo for an applied fix.

Requests in rt.summary()

rt.summary().requests (after 0.3.0-beta.1, issue 94) answers "did GenClass see my requests?" without a model decision. Counted from the fetch and XHR operations the runtime records; reporting only (never a situation, a fact, the gate or the model), and not sent by telemetry. It is counted in the measured checks' chunk (src/visual/requests.ts), so it is present once that chunk has loaded: wherever visual is on (development hosts by default, visual: true elsewhere), when the devtools overlay mounts, or after rt.checkNow(). On load it counts the requests the runtime still holds (its op store keeps the last 2,000 operations of every kind), then follows its event stream.

interface RequestHealth {
  requests: number;            // fetch and XHR requests started (GenClass's own retries and hedges excluded)
  "2xx": number; "3xx": number; "4xx": number; "5xx": number; // answers by status class
  network: number;             // no answer: network error, timeout (XHR timeout, AbortSignal.timeout), status 0
  aborted: number;             // aborted by the app
  notJson: number;             // fetch answers read with response.json() whose Content-Type is not JSON
  byGenClass: number;          // answered by GenClass (blocked, reused response; guard and heal only)
  pending: number;             // still in flight
  latency?: { n: number; p50: number; p95: number }; // ms, answers that did not fail (the learned baselines, last 64 per endpoint)
}
interface RequestSummary extends RequestHealth {
  hosts: Record<string, RequestHealth>; // "same-origin", "api.example.com", ...; "other" past 30 hosts
}
  • requests: 0 after the app sent requests means GenClass did not see them: it was loaded after them (a deferred script tag starts after classic scripts), or the app calls a fetch it captured before GenClass loaded. Requests counted and nothing in detections means it saw them and none was salient: a 404 or a non-JSON answer the app's own catch handles is counted here, not detected.
  • notJson reads the Content-Type header, never the body: an API that sends invalid JSON as application/json is not counted, and neither is XHR's responseType = "json". Its first-load cost is one flag on the request (34 B gzip).
  • A sink's summary record carries the totals with an empty hosts (the per-host counts stay in rt.summary()).
  • A request answered by GenClass from its cache before the chunk loaded counts by its status, not as byGenClass.
  • A request still pending past visual.checks.stallMs is also a request-stalled check (Visual checks, below).

Audit trail

rt.audit(n?) returns the last n entries (all kept by default) of a structured, JSON-serialisable record of everything the runtime decided and did; JSON.stringify(rt.audit()) exports it, and it keeps working after destroy(). One entry per decision (acted on or not), action, undo, breaker trip or reset, and control change:

interface AuditEntry {
  schema: 1; seq: number;              // 1, 2, 3, ... without gaps
  at: number;                          // runtime clock (ms)
  kind: "decision" | "action" | "undo" | "breaker" | "control";
  sessionId: string;
  mode: "off" | "observe" | "guard" | "heal";  // effective mode for the subject (or the session)
  requestedMode: "observe" | "guard" | "heal";
  aggressiveness: number; profile?: "cautious" | "balanced" | "eager";
  model: { name: string; state: string; version?: string; variant?: string; device?: "webgpu" | "wasm"; sha256?: string };
  decisionId?: string; actionId?: string; trigger?: TriggerKind; subject?: string; label?: string; correlationId?: string;
  // decision
  diagnosis?: string; diagnosisConfidence?: number; proposed?: string; probabilities?: Record<string, number>;
  ran?: string; executed?: boolean; tier?: "passive" | "guard" | "heal"; reason?: string;
  gate?: { kind: "mass" | "gain"; thresholds: { report: number; guard: number; heal: number };
           source: { report: GateSource; guard: GateSource; heal: GateSource };
           candidate?: string; mass?: number; threshold?: number; gain?: number; margin?: number };
  held?: boolean; holdBudgetMs?: number; latencyMs?: number; shadow?: { action; tier; wouldPass; reason? };
  // action
  ok?: boolean; error?: string; changed?: string; undoable?: boolean; late?: boolean; dropped?: string[];
  // undo: byRuntime (true for rt.disable({ undo: true }), which the breaker does not count)
  byRuntime?: boolean;
  breaker?: { tripped: boolean; reason: "undos" | "errors" | "reset"; decisionIds: string[]; counts: { undos: number; errors: number } };
  control?: { what: "setMode" | "setAggressiveness" | "pause" | "resume" | "enabled" | "disable"; from?: string | number; to?: string | number };
}

GenClass.init({
  requests: { ignore: ["/my-log"] },
  audit: { size: 5000, sink: (e) => navigator.sendBeacon("/my-log", JSON.stringify(e)) },
});

audit.size (default 1,000, at most 10,000; 0 keeps none) bounds memory; audit.sink gets a copy of every entry, in order, on a microtask after it was recorded (never while a subject is held); a throwing sink is contained (one warning a minute). Query and fragment values in subject, changed and error 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 GenClass never sends it anywhere. A sink that ships entries with fetch should send to an endpoint listed in requests.ignore (or use navigator.sendBeacon, which GenClass does not wrap): otherwise its requests are observed like any app request and may themselves be decided about. AUDIT_SCHEMA is exported.

Plugins

rt.use(plugin: Plugin): () => void
rt.action(def: ActionDef): () => void
rt.question(def: StandingQuestion): () => void

interface Plugin {
  name: string;
  setup?(api: PluginApi): void | (() => void);   // api: emit, recordOp/endOp, runInOp, user, reportError, on, clock, stores, runtime
  facts?(sit: SituationDraft): string[];          // extra facts about your domain, added to every situation
  actions?: ActionDef[];
  questions?: StandingQuestion[];
  diagnoses?: Record<string, string>;             // extra diagnosis labels
}
interface ActionDef {
  name: string; description: string; on: TriggerKind[];
  tier?: "guard" | "heal";                        // default "heal"
  applicable?(sit: SituationDraft): boolean;
  run(ctx: ActionContext): void | Promise<void>;
}
interface ActionContext {
  trigger; decision; situation; runtime;
  builtin(name: string): Promise<boolean>;        // run a built-in action, e.g. "discard" or "retry"
  describe(changed: string): void;                // what this action altered (shown in reports)
  onUndo(fn: () => void): void;
}
interface StandingQuestion { id: string; on: TriggerKind[]; question: Question; always?: boolean; onAnswer?(a: Answer, ctx): void }
rt.use({
  name: "presence",
  setup(api) {
    const ws = new WebSocket("/presence");
    ws.onmessage = (e) => api.emit("presence", { users: JSON.parse(e.data).length });
    return () => ws.close();
  },
  facts: (s) => (s.trigger === "mutation" ? [`${onlineUsers()} other users are editing this document.`] : []),
  actions: [{
    name: "ask_to_reload", description: "keep the write and ask the user to reload the document", on: ["inconsistency"], tier: "heal",
    run: (ctx) => { showReloadBanner(); ctx.describe("Showed the reload banner."); },
  }],
});

If a custom action does not call ctx.builtin(...), the passive action runs after it (the held write applies, the request is sent).

Headless use (tests, SSR, simulation)

import { createRuntime } from "@genclass/runtime";
const rt = createRuntime({
  clock,      // { now(), setTimeout(fn, ms), clearTimeout(h), afterTask(fn) }; default: the real clock
  global,     // the object whose fetch / XMLHttpRequest / WebSocket / addEventListener / ... are instrumented; default globalThis
  decider,    // any DecisionProvider; default none (createRuntime never loads the model unless model: {...} is given)
              // telemetry is off in createRuntime unless telemetry: true / {...} is given
  app: () => ({ title: "Shop", route: "/cart" }),   // default: global.document.title and global.location.pathname
  hooks: { opCreated(op) {}, mutationProposed(m) {} },
});

interface DecisionProvider {
  readonly status: ModelStatus;
  ready(): Promise<void>;
  evaluate(req: { trigger; state; questions; priority?; subject?; notOffered?; timeoutMs? }): Promise<Record<string, Answer>>;
  onStatus?(fn): () => void;
  dispose?(): void;
}

The runtime only uses the injected clock (no Date.now, Math.random or global timers), so a virtual clock makes runs deterministic: the same inputs give byte-identical situations. Pass situation: { budget } to fix the situation size.

The local model host is exported for direct use: createModelHost(options) (a DecisionProvider with measure(), evaluateDetailed(), stats), DEFAULT_MODEL_BASE_URL, and the model errors (ModelNotReadyError, MaxTokensExceededError, ModelTimeoutError, ModelBusyError, ModelLoadError, ...; each has a code). Any provider error makes the runtime fail open. The device floor is exported too: deviceEnv(globalThis) (what the page can tell about the device: deviceMemoryGB, saveData, mobile, cores, webgpu, effectiveType), evalLoadIf(modelOptions, env, warn) ("load" | "lazy" | { skip: "device-memory" | "save-data" | "loadIf" }) and DEFAULT_LOAD_IF ({ minDeviceMemoryGB: 4, saveData: "skip" }, since 0.3.0; navigator.deviceMemory is quantised to powers of two, so 4 skips exactly the devices reporting 2 GB or less).

Adapters and devtools

Subpath exports: @genclass/runtime/react (useGenClassState(name, initial, opts?), useAtom(atom), useGenClass()), @genclass/runtime/redux (genclassEnhancer(runtime, { name })), @genclass/runtime/zustand (genclass(runtime, name)(stateCreator)), @genclass/runtime/devtools (mountDevtools(runtime, opts?)), @genclass/runtime/discover (automatic state discovery for GenClass.init({ autoState: true }); import it first), @genclass/runtime/sinks (consoleJson, webhook; 0.3.0) and one subpath per adapter, @genclass/runtime/sinks/{console-json,webhook} (each also its default export; see "See it where you already look"; there is no Sentry, PostHog or Datadog adapter in the free runtime), @genclass/runtime/visual (checkPage(options?), the measured visual checks without a runtime; 0.3.0). They are documented with their source. The devtools overlay is in genclass.dev's black design whatever the page's theme (theme: "dark", the default since 0.3.0; "light" is the same design on white, "auto" follows prefers-color-scheme). To integrate another state library use:

rt.adapter<T>(name: string, io: { get(): T; set?(v: T): void; subscribe?(fn: () => void): () => void }, opts?): {
  propose(w: { fn?: (prev: T) => T; value?: T; commit: (next: T) => void }): void;   // commit runs when (and if) the write applies
  dispose(): void;
}

Coding agents: @genclass/runtime/agent

Design: AGENT.md. Development builds only; one file with no imports of runtime code, so it adds nothing to the first load. Pairs with the @genclass/mcp server.

import { connectAgent } from "@genclass/runtime/agent";

connectAgent(runtime, opts?: {
  url?: string;              // the bridge, loopback only (default __GENCLASS_AGENT_URL__, else http://127.0.0.1:47357)
  schedule?: ScheduleSpec | string;  // this page load's schedule; default __GENCLASS_SCHEDULE__, then ?genclass-schedule=,
                                     // then the bridge's answer to hello
  anyHost?: boolean;         // also on pages not served from this machine (LAN, *.local; default false: inert there)
  settleMs?: number;         // quiet time before a snapshot (default 400)
  dom?: boolean;             // page text and form values in snapshots (default true)
  helloTimeoutMs?: number;   // how long a page load's first responses wait for the bridge's hello (default 600)
  hold?: boolean;            // false: never hold, even with a schedule (default true)
  holdProtected?: boolean;   // also hold requests.protect and mode-"off" routes (default false: recorded, never held)
}): AgentConnection;

interface ScheduleSpec {
  order: "natural" | "fifo" | "seeded" | "given";
  seed?: number;             // seeded: the seed (also seeds delay)
  given?: string[];          // given: request keys ("GET /api/search#3") in delivery order
  focus?: string[];          // seeded: signatures delivered after every overlapping response
  delay?: { minMs: number; maxMs: number };   // extra latency per response, hashed from seed and key
  maxHoldMs?: number;        // default 1500, beyond the response's own delay: then delivered, and the run is marked forced
  gapMs?: number;            // default 30: time between two releases
}

interface AgentConnection {
  readonly url: string;
  readonly reason: string | null;    // "not-dev-host" | "not-loopback" | "no-runtime" | "disconnected" | null
  readonly connected: boolean;       // the bridge answered hello
  readonly run: string | null;       // the bridge's id for this page load
  readonly schedule: ScheduleSpec | null;
  readonly settled: boolean;         // at a quiet point
  recorded(): RecordedSchedule;      // { schema: "genclass-schedule/1", route, spec, steps, order, forced, truncated }
  witnesses(): Witness[];            // overwrote-newer and identical-in-flight facts
  errors(): ErrorRec[];              // uncaught errors with stack and causal chain
  snapshot(): Snapshot;              // store fields, page text, form values, requests per signature; at most 1,000
                                     // store fields (96,000 characters): registered stores, then component state, then
                                     // other discovered stores, shallowest paths first; stateLeft / stateTotal say how
                                     // many were left out of how many (set only when some were); live: the
                                     // lines of ARIA live regions (status messages, alerts, toasts), when any
  whenSettled(timeoutMs?: number): Promise<boolean>;
  disconnect(): void;                // deliver anything held, restore the runtime
}

Connected only on pages served from this machine (localhost, 127.x, [::1], *.localhost) unless anyHost (then the bridge needs the exact origin in GENCLASS_AGENT_ORIGINS). Without a schedule only a page load's first responses wait for the bridge's hello (helloTimeoutMs); a response counts as delivered when the app receives it. Also exported: parseSpec(v) (a schedule from a URL form, "fifo", "seeded:7", "given:[...]", or JSON), hash32, AGENT_SCHEMA ("genclass-agent/1"), SCHEDULE_SCHEMA ("genclass-schedule/1"), DEFAULT_AGENT_URL, isLoopbackUrl, isDevHost. While connected, the page has window.__GENCLASS_AGENT__ (the connection), which generated regression tests read.

Types

interface Decision {
  id: string; trigger: TriggerKind; subject: string; at: number; latencyMs: number; model: string;
  diagnosis: string; diagnosisConfidence: number; diagnosisProbabilities: Record<string, number>;
  action: string /* the action that ran, else the model's choice */; confidence: number /* probabilities[action] */;
  probabilities: Record<string, number>; executed: boolean; reason?: string; facts: string[];
  tier: "passive" | "guard" | "heal"; ran: string; answers: Record<string, Answer>; subjectRef?: SubjectRef;
  candidate?: string /* most probable permitted action */; mass?: number /* summed probability of the permitted actions */;
  gateKind?: "mass" | "gain"; threshold?: number /* mass gate: what mass was compared with */;
  gain?: number; margin?: number /* gain gate: ĝ of the candidate and its tier margin */; thresholdSource?: "policy" | "model" | "default";
  detection?: "likely" | "possible" /* 0.3.0: set on detections (see "Reports, explain and undo") */;
}
type Detection = Decision;
interface ActionRecord { id: string; decisionId: string; action: string; tier; trigger; subject: string; at: number; ok: boolean; error?: string; changed: string; undo?: () => void; late?: boolean; dropped?: string[] /* delivery discard: fields dropped */ }
type SubjectRef = { kind: "delivery"; op: number; paths?: string[]; store?: string } | { kind: "mutation"; ... } | ...
interface Explanation { message: string; decision: Decision; situationText: string; facts: string[]; timeline: string[]; answers: Record<string, Answer>; action?: ActionRecord; changed?: string; gates?: EffectiveGates }
interface Expectation { kind: "request" | "response" | "write" | "follow-up" | "navigation"; route: string; action: string; op: number; signature?: string; field?: string; after?: string; to?: string; seen: number; of: number; sessions: number; waitedMs: number; usualMs?: number; at: number; message: string }
interface LearnedFlow { route: string; action: string; n: number; repeats: number; sessions: number; settleMs?: { p50; p95 }; children: { signature; n; share; onRepeat; expected; expectedOnRepeat; protected?; missed?; delayMs? }[]; writes: { signature; field; n; of; share; expected; missed? }[]; followUps: { after; then; n; of; share; expected; missed?; delayMs? }[]; nav: { to; n; share; expected; missed? }[] } // missed: reported missing, not expected until seen again
interface Learned { route: string; session: number; flows: LearnedFlow[]; deviations: Expectation[]; relations: { text: string; held: number }[] }
interface EffectiveGates { kind: "mass" | "gain"; tauGain?: number; trigger?: TriggerKind; report: number; possible?: number /* 0.3.0: observe's possible tier */; guard: number; heal: number; source: { report: "policy" | "model" | "default"; guard: …; heal: … } }
interface RtEvent { seq: number; t: number; kind: "user"|"op.start"|"op.end"|"state"|"error"|"nav"|"perf"|"storage"|"custom"|"decision"|"action"; name: string; op?: number; cause?: number; data?: Record<string, unknown> }
interface Op { id: number; kind: "user"|"fetch"|"xhr"|"ws"|"task"|"timer"|"genclass"; name: string /* e.g. "GET /api/x", "WS message /live", "SSE update /stream" */; detail?: string; start: number; end?: number; status?: "ok"|"error"|"aborted"|"blocked"; code?: number | string; cause?: number; root?: number; attempt: number; reads: Map<string, number>; identity?: string }

Privacy: values are summarised and redacted before they reach a situation. A value is redacted when its leaf field names a secret (password, passcode, pin, token, secret, cvv/cvc, ssn, iban, otp, cookie, authorization, card number, credit card, api key, session id, ...; payment.card.number counts), never because of the store's name: in an auth store, auth.token is redacted and auth.loading or auth.user.name are not. Under a container that names a secret (credentials.password.value) strings are redacted too, and under auth/session/cookie only opaque credential-like strings (JWTs, API keys). Query parameters and body keys follow the same rule; password inputs and inputs with autocomplete cc-* / one-time-code are never recorded. Element text such as a kanban "card" is not a secret. Response bodies are read only from a clone the runtime already keeps (≤ 256 KB) and only to compare them with the store. Decisions are made in the browser by the local model; the only data GenClass sends is its anonymous telemetry (on by default with GenClass.init() in a browser; TELEMETRY.md), which includes the redacted situation text.